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

featury

v0.3.17

Published

Send usage and feedback to featury from your website or your server.

Readme

featury

Let people send a rating or a message to your team. Add your help button, or send feedback and errors from your server. Automatic browser collection needs consent and collection settings.

Use the script tag or install the package with bun add featury. Installing it does not turn on browser usage, automatic errors or check-ins.

On your website

Add the script tag

Paste these two lines into your page's <head>:

<script>window.featury=window.featury||function(){(featury.q=featury.q||[]).push(arguments)}</script>
<script async src="https://widget.featury.app/v1/loader.js" data-key="fpk_..."></script>

fpk_... is a widget key. Settings > Install in featury has it, with these lines ready to copy. It is fine in a web page, because featury takes it only from the websites you list for it. Never put a server key (fsk_...) in a page.

The first line lets you call featury(...) before the loader arrives. Those calls wait, then run in order.

| Attribute | What it does | | --- | --- | | data-key | Your widget key. Required. | | data-consent="granted" | Counts visits without waiting for consent (see below). | | data-environment | production unless you set it. On localhost, 127.0.0.1, [::1] and *.local it is development. | | data-release | Your release, like a commit SHA. | | data-api | Where to send when you host featury yourself: your API_URL plus /widget. | | data-debug | Prints in the console why a call wasn't counted. |

If your site's Content Security Policy turns on Trusted Types (require-trusted-types-for 'script'), add featury to its trusted-types list:

Content-Security-Policy: require-trusted-types-for 'script'; trusted-types featury

The loader adds the help button and the feedback form from two files of its own version, checked by their hash. To add them, it makes one policy named featury and uses it only for those files' addresses. Without that name in the list, the feedback form says it didn't load and offers Try again, and the help button doesn't show.

Or install the package

bun add featury

Then start it with your widget key:

import { init } from "featury";

init("fpk_...");

With npm you call each function directly: track("signup") is featury("track", "signup") from the script tag. init takes the attributes as options (consent, environment, release, endpoint for data-api, debug), plus launcher: false to hide the help button. It starts featury once per page and does nothing during server rendering.

Browser collection and server feedback, errors and source maps use https://widget.featury.app by default. Allow that host in your site's connect-src policy. The personal-key management client uses https://api.featury.app. Self-hosted installs keep their own endpoint or data-api, set to the instance's API_URL plus /widget for collection.

If the connection drops while loading its settings, it tries up to three times, with a five-second limit per attempt. If those fail, it tries again when the browser comes back online. Tracking still needs consent and the key's collection settings.

Use help without tracking

In Settings > Install, leave Count visits off for the key and turn on the help parts you need. Leave consent pending (the default). Do not call consent("granted") or identify. Help, support questions and feedback a person chooses to send still work, including with Global Privacy Control. Usage, browser errors, check-ins, account details and unread dots stay off. The panel keeps its conversation in memory, not browser storage. Show Sent only after a feedback receipt confirms it.

For a public site and a signed-in app, make two widget keys under the same product. Give each only its own website origin and install its own key there. Both use the same published help pages; their help-button settings can differ. Call init once per page, not again on each screen, and reuse feedback for ratings instead of making a second client. On sign-out or an account change, call reset({ discardFeedback: true }) to clear the panel, cancel pending feedback and stop edits through old handles, including notes made without tracking consent. A request already sent cannot be recalled. Plain reset() preserves anonymous chosen feedback. A public key does not authenticate a person or give support access to their private account.

Send a browser rating

After init, send a thumbs-up for Search:

feedback("search", "up");

Import feedback from featury. In React, import Feedback from featury/react:

<Feedback feature="search" />

The control sends the rating right away. A thumbs-down opens an optional reason panel. For a message form, use <FeedbackForm product="Your product" />. Script-tag pages can use <featury-feedback feature="search"></featury-feedback> or <featury-feedback-form product="Your product"></featury-feedback-form>; their view loads only when needed, from the same pinned SDK version.

Choose a preset for feedback beside a specific item:

<Feedback preset="ai-answer" />

Add feature="chat.answers" and an opaque item="answer:msg_81" to say which feature and item it is about. Never put the prompt, answer or search text in item.

| preset | What it asks | | --- | --- | | ai-answer | Whether an answer helped, with optional reasons such as wrong or missing information. | | search | Whether the search results helped. | | search-result | Whether one result helped. Give each result its own item. | | no-results | What the person hoped to find. Opens a message form. | | error-report | What went wrong. Opens a message form and sends kind: "problem". | | completed-report | Whether a completed report was useful. | | satisfaction | How the experience went. | | general | A general message, with problem, idea and question choices. |

Intent and appearance are separate. Set presentation to "thumbs" (the rating default), "stars" (1 to 5, with numbers), "five-point" (1 to 5), "score" (0 to 10) or "form". Numeric controls send ordinary rating and scale fields. They do not calculate NPS or any other report. FeedbackForm defaults to a form but accepts the same presets and presentations.

A low rating (thumbs down, 1–2 out of 5 or 0–4 out of 10) opens an optional reason panel. why="always" opens it for any rating; why="never" hides it. Override a preset's words with question and choices, or pass choices={[]} for free text only. Ratings, reasons and later edits share one receipt and record. A failed send keeps the message and offers retry when it can work. On desktop the reason panel opens beside the control; on phones it is a sheet.

The HTML elements accept the same preset, presentation, question, why and locale attributes, for example <featury-feedback preset="search" presentation="five-point">. Use <option value="outdated">Out of date</option> children to replace the preset's reasons.

Nothing opens on a timer or sends on mount. To sample occasional questions, use the existing checkIn or useFeedback flow below. The error preset sends only the report a person chooses to write; it does not capture an automatic error or retry your app's failed action. Keep your app's recovery button beside it. Presets never copy prompts, answers, queries or page paths.

Chosen feedback works without tracking consent and with Global Privacy Control on. In that case it carries no browser id, session or account, reads no browser storage and stays only in memory while it waits. Keep the page open until delivery is confirmed. Typed follow-up email is offered only when email notices are available. No prebuilt form attaches the page address or page text.

A handle keeps one id across retries and edits:

const note = feedback("search", "down");
note.update({ choice: "incomplete" });
note.getState(); // status is "stored" only after a typed receipt confirms this revision

update changes only the fields you pass and keeps the rest, with one exception: a new rating without a choice drops the old one, so note.update({ rating: "up" }) never keeps the reason given for the thumbs down.

States include sending, stored, unconfirmed, failed, expired, unavailable and preview. subscribe watches them; retry resends the same pending revision when allowed. A received rating stays received if its later reason cannot be confirmed. Edits end after one hour. A 204 response, a queued beacon or await flush() does not prove the note was saved.

The script stub returns nothing before the loader arrives. Use a final callback when you need the handle from a queued call:

featury("feedback", "search", "up", (note) => note.subscribe(showDelivery));

createFeedbackClient("fpk_...") makes a separate memory-only feedback client. It sends no visit, reads no config and has no identify, consent or error-capture method. Use it when a form must not share the page's tracking identity. Its optional beforeSend filter can drop a note with null.

Open feedback from your own button

After init, open the message form for Search:

openFeedback("search");

Import openFeedback from featury. The form opens beside the focused button on desktop and as a sheet on phones. closeFeedback() hides it. Closing, pressing Escape or clicking outside keeps the text and any pending send. Reopening the same feature and item brings back that form, not a new record. Focus returns to the button. Reset, a new person or consent withdrawal clears the mounted forms so the next person cannot see an earlier draft.

With the script tag, call featury("openFeedback", "search"), or put this on your own button:

<button data-featury-feedback="search">Send feedback</button>

Buttons added later work too. An empty data-featury-feedback opens a general form without a feature. Add data-featury-item, data-featury-preset, data-featury-presentation or data-featury-label for explicit context. No query, answer, prompt or page path is copied.

In JavaScript, the optional second argument uses the same preset and presentation as inline controls. anchor chooses a button when it is not the focused element:

openFeedback("chat.answers", {
    item: "answer:msg_81",
    preset: "ai-answer",
    presentation: "thumbs",
    anchor: answerFeedbackButton,
});

Opening defaults to a message form. An explicit rating presentation keeps the rating and its optional reason in the same popover or sheet, using the same record and delivery receipt. A failed view load says so beside the button and offers Try again. Opening never sends a note.

Occasional questions and browser errors

checkIn({ id: "search-results", feature: "search" }) returns a handle whose visible flag says when the question may show. Attach its observe(element) to the control so it counts only after half the control is visible for 500 ms. send answers it; dismiss closes it. React's useFeedback({ checkIn: "search-results", feature: "search" }) exposes the same engine and a ref. Inline <Feedback> controls do not create shown/dismissed counts.

Check-ins and browser errors need tracking consent, a key with tracking allowed, global usage collection and signals intake enabled. GPC keeps both off. Missing or failed config grants no permission. Usage collection stays off until a separate release approval. The Browser errors preference alone never turns collection on.

Defaults are one question per page view, seven quiet days across the product, 30 days per check-in/feature and no repeat for the same item on this device. Sampling uses FNV-1a over the product key, consented browser id and check-in id, so a sign-in does not resample the device. Requests > Feedback > How often we check in can change the defaults. An open page keeps its init config; new loads may take about 5.5 minutes to pick up changes. Counts are approximate because an API restart can lose buffered items.

error(err) reports a caught error only when capture is allowed. Automatic capture listens for uncaught errors and rejected promises. It sends bounded, cleaned messages/stacks and app version, not page text, screenshots, request bodies, headers or cookies. Cleaning can miss a name or secret. React 19 can pass reactErrorHandler() as createRoot's onCaughtError, onUncaughtError and onRecoverableError. It sends the error with its component stack, then does what React does without the option: a caught error goes to console.error, and any other goes to the browser's reportError, which featury counts once. An error no boundary caught is sent as unhandled. To run your own callback instead of React's default, pass it in: reactErrorHandler(myCallback). React 18 has no such options: call reactErrorHandler()(error, info) from an error boundary's componentDidCatch, where React has already logged the error.

With debug: true in development, preview is on by default: notes are validated but never saved, and check-ins create no counts. Pass preview: false for a real test note. enabled: false, an invalid key and server rendering start no traffic, storage or listeners. beforeSend and beforeSendError can filter chosen notes and errors; a throwing filter drops its input safely.

Count what people do

featury("track", "signup");
featury("track", "export_csv", { feature: "reports", value: 3 });

Turn on Count visits in Settings > Install first. Then, once the person agrees (see below), featury counts page views by itself, single-page apps included. track counts one thing a person did. Its name can use letters, digits, _, ., : and -, up to 64 characters. feature is your feature key, and value is any number you want to add up.

React

import { Feature, useTrack } from "featury/react";

function SearchPage() {
    return (
        <Feature name="search">
            <SearchBox />
        </Feature>
    );
}

function SearchBox() {
    const track = useTrack();
    return <button onClick={() => track("search_run")}>Search</button>;
}

useTrack sends the nearest <Feature name> as the event's feature, unless the call names its own. Call init once, where your app starts.

Ask for consent

featury counts no usage and stores nothing on the device until the person says yes and the key's config permits usage. They can still choose to send feedback without tracking. When they accept your consent banner, call:

featury("consent", "granted");

featury doesn't remember the answer, so your banner calls this on every page load. featury("consent", "denied") stops counting and removes everything featury stored in the browser. When the browser sends Global Privacy Control, featury counts nothing on that page and ignores "granted".

If you don't need consent, add data-consent="granted" to the script tag, or pass { consent: "granted" } to init.

Before consent, the help button and the panel still work. track calls are dropped, and the last identify waits in memory until consent.

What featury keeps in the browser

Only after consent, in localStorage:

| Key | What it holds | | --- | --- | | featury.a | A random id for this browser. | | featury.s | The current visit: its id, when it was last active, its campaign (utm_*), and the website the person came from (only its address, like https://news.example). | | featury.u | The id of the last person who signed in, to notice when someone else does. | | featury.o.<page> | Unconfirmed usage events, retried within 23 hours. | | featury.f.<page> | Unconfirmed identified feedback, scoped to key and endpoint, at most 100 notes and 128 KiB, expiring after 30 days. | | featury.c.<scope> | Check-in quiet times and item digests: at most 100 check-in/feature pairs and 1,000 items. | | featury.n | The newest What's new edition number when the panel last opened, so the panel can mark newer ones. |

Feedback without a tracking identity is never persisted. Withdrawal and reset cancel old-person feedback retries and clear consented drafts and check-in memory. A request already in flight cannot be recalled. A note without an account, browser id or email cannot be found later through a person's data request. Keep personal details out of its words.

A visit ends after 30 minutes without activity, or at midnight UTC.

Say who is signed in

featury("identify", {
  id: user.id,
  email: user.email,
  name: user.name,
  traits: { plan: user.plan },
  userHash,
});

userHash proves the id came from your server, so nobody can pose as someone else. Make it on your server with your identity secret, from Settings > Install > Signed-in people. Never send the secret to the browser. With this package:

import { userHash } from "featury/server";

const hash = await userHash(process.env.FEATURY_IDENTITY_SECRET ?? "", `${user.id}`, user.email ?? "");

userHash returns a Promise, so await it. Without this package, sign id + "\n" + (email or "") with HMAC-SHA256 and send it as lowercase hex. In Node:

import { createHmac } from "node:crypto";

const userHash = createHmac("sha256", process.env.FEATURY_IDENTITY_SECRET)
  .update(`${user.id}\n${user.email ?? ""}`)
  .digest("hex");

In Python:

import hashlib, hmac, os

user_hash = hmac.new(
    os.environ["FEATURY_IDENTITY_SECRET"].encode(),
    f"{user.id}\n{user.email or ''}".encode(),
    hashlib.sha256,
).hexdigest()

In PHP:

$userHash = hash_hmac(
    'sha256',
    $user->id . "\n" . ($user->email ?? ''),
    getenv('FEATURY_IDENTITY_SECRET')
);

In Ruby (a nil email becomes "", the same message):

require "openssl"

user_hash = OpenSSL::HMAC.hexdigest(
  "SHA256",
  ENV.fetch("FEATURY_IDENTITY_SECRET"),
  "#{user.id}\n#{user.email}"
)

A number id works as it is: featury sends 42 as "42", the same text the examples sign. When the person signs out, call featury("reset"), so the next person starts with a new visit.

traits holds details about the person's account, like their plan. Support answers can use the ones whose names you list in Settings > Install > Help button, under Details the answers can use (up to 10, and Count visits must be on). The script sends a listed trait with a question only after consent, with usage counting on and no Global Privacy Control. It never sends email or name there, and it refuses names that look personal, like user_id or plan_name. Keep personal data out of the values you list: featury masks common patterns, but it can't catch every name.

Open the help panel

The help button opens your help center in a panel. To open it from your own link or button:

featury("open");

That opens the panel on its home page. featury("open", part) opens it at one part: "search", "ask", "news" or "feedback". The button's side, color and words come from Settings > Install. Turn the button off there and featury("open") still works.

You can also mark a button or link in your HTML, with no code:

<button data-featury-open>Help</button>
<button data-featury-open="ask">Ask a question</button>

The panel opens next to it, below it when there is room and above when there is not. Add data-featury-placement="top" or "bottom" to choose. A second press, a press anywhere else or the Escape key closes it, and focus goes back to your button. On a phone it fills the screen. A link keeps going to its address until the script has loaded, and when pressed with Ctrl, Cmd or Shift.

To show the panel inside a page, with no popup, give a box the attribute and a height:

<div data-featury-embed style="height:600px"></div>

The panel fills the box and stays open. It has no Close button.

Each time the panel opens, the script tells it the page's path. When that path matches one of your public screens (Help center > Screens), the panel lists that screen's help pages first, and a question tells the support agent which screen the person is on. A help page link to a screen like /projects/:id opens that person's own project, with the id taken from the current path.

The panel matches the path inside the browser. With a question, featury gets only the matched screen's pattern, like /projects/:id, never the path or its ids. When a private screen could match the same path, the panel sends nothing for it. This needs the address under Your app in the help center's settings to have the same origin (protocol, host and port) as the page running the script. The panel reads the path when it opens: if the page changes while it stays open, it keeps the earlier screen until the next open.

With consent and usage counting on, What's new marks the editions published since this browser last opened the panel (featury.n above). Without them, it marks none.

featury("close") only hides the panel: the next open on this page shows the same conversation. featury("consent", "denied"), featury("reset") and an identify with a different person remove the panel with its conversation and unsent edits, so the next open starts fresh. None of them deletes what featury already saved, and a question already sent can't be recalled.

Find out why a call wasn't counted

Add data-debug to the script tag, or call featury("debug", true). The console then says what featury didn't count and how to fix it. Three lines always print: a server key in the page, no key on localhost, and a website the key doesn't allow.

On your server

Send what your users tell you about your product to featury, from your server. Send the errors your server catches too.

Send feedback

import { Featury } from "featury/server";

const featury = new Featury("fsk_...");
featury.feedback({ feature: "search", rating: "up" });
await featury.flush(); // sends now; a server that keeps running can leave this out

That queues one thumbs-up for your "search" feature. fsk_... is a server key from featury. Keep it on your server. Anyone who has it can send notes as your product, and can read, export or erase what one person sent. If it leaks, turn it off in featury and make a new one.

feedback() returns the note's id right away and never throws. Notes go out in the background, in batches, about once a second. In a script or a test, await featury.flush() before it ends, or the notes still waiting are lost. flush() is best-effort: it does not prove featury saved them.

Wait for a saved note

If you keep pending notes in your database, send one and wait for its receipt:

const receipt = await featury.sendFeedback(note);

note takes the same fields as feedback(), but its id is required. Save that UUIDv7 with the note before sending. Retry the same id, never a new one. This call skips the memory queue and resolves only with a receipt for that one note. accepted: 1 means saved now; duplicates: 1 means already saved. Both are safe to mark delivered in your database.

A per-item refusal returns accepted: 0, duplicates: 0 and the reason in dropped[0]. Do not mark it delivered. A note refused as erased must not be retried. Check warnings too: for example, request_not_found means the note was saved without its requested link.

An unreachable server, a refused key, a 204 or a missing receipt rejects the promise. Keep the note pending and handle the error. FeaturyError includes status, code and retryAfterSecs. beforeSend can change the note, but not its id; returning null rejects with FILTERED and sends nothing. A disabled or shut-down client rejects instead of reporting delivery.

When your database owns retries, turn off the SDK's extra attempts:

const featury = new Featury({ key: "fsk_...", timeoutMs: 10_000, maxRetries: 0 });

timeoutMs limits each HTTP attempt, including reading the reply. The default is 30,000 ms. maxRetries defaults to 2 extra attempts after a retryable failure; 0 makes one attempt. These options apply to every HTTP call on this client. With retries off, use the error's retryAfterSecs to decide when your worker sends the same saved id again. They do not make feedback() or flush() confirm delivery; use sendFeedback() for that.

Add what they said

Send the same id again to add the reason or change the vote:

const id = featury.feedback({ feature: "search", item: "q_8f2c", rating: "down" });
featury.feedback({ id, choice: "outdated", text: "Prices were from last week" });

A new note needs a rating, a choice or some text. Sent again with its id, it can add any of them, or just kind, followUp and email. rating is "up", "down", or a number: 1 to 5, or 0 to 10 with scale: 10.

Say who it was

featury.feedback({ feature: "search", rating: "down", user: "u_42" });

user is your own user id. featury shows it to your managers, and uses it to erase or export one person's notes.

To let the person hear back when it's fixed, add followUp: true and their email. Without followUp: true the email isn't stored.

Link it to a request

featury.feedback({ feature: "search", rating: "down", request: "bug/search-shows-old-prices" });

See what came of it

const receipt = await featury.feedbackStatus(id);
// { id, status: "planned", request: "bug/search-shows-old-prices", url: "https://my.featury.app/w/..." }

status is received, planned, building, shipped or not_planned. It's safe to show to the person who sent the note. url opens the request in featury, for your team.

If you sent the note with your own externalId, look it up by that instead:

const receipt = await featury.feedbackStatus({ externalId: "inbox:42" });

A second note with the same externalId isn't stored, so a retry can't count twice.

Send errors

try {
    await chargeCard(order);
} catch (err) {
    featury.error(err);
    throw err;
}

That sends the error to Requests > Errors in featury, where it is grouped with the others like it. error() takes anything you catch: an Error, a string or any other value. It returns the error's id right away and never throws.

Say where it happened and who hit it:

featury.error(err, { feature: "checkout", route: "/orders/:id", user: "u_42", handled: false });

| Option | What it is | | --- | --- | | feature | Your feature key, as for notes. | | route | The path it happened on, like /orders/:id. It must start with /. featury matches it to a screen and doesn't keep it. | | user | Your own user id. featury counts how many people hit the error. | | handled | false when nothing caught it. The default is true. | | service | Your service's name, like "api", when one product has more than one. | | sessionId | The visit's id, if you have one. It must be a UUID. | | item | The thing it happened on, as for notes. | | id | Your own id for this error, a UUIDv7. Sent again, it counts as the same error. |

The same error again within a minute isn't sent twice. It adds 1 to the first one's count and returns the same id. The same error means the same type, the same message once numbers and ids are left out, the same first line of the stack, and the same user, service, feature, route, item and handled. Errors from two different users always count apart.

The SDK sends the message and stack as they are. Before featury saves a sample, it replaces emails, card numbers, phone numbers and long numbers in the message. Only people with the Manager role or higher see a sample's message.

To change an error or drop it before it's sent, pass beforeSendError. Return null to drop it:

const featury = new Featury({
    key: "fsk_...",
    beforeSendError: (error) => (error.type === "AbortError" ? null : error),
});

featury answers as soon as an error is in its memory, and saves it a few seconds later. If featury restarts in between, that error can be lost.

Which deploy it came from

The SDK sends your release with every error. It takes the first of these that is set: the release option, then FEATURY_RELEASE, VERCEL_GIT_COMMIT_SHA, RENDER_GIT_COMMIT, RAILWAY_GIT_COMMIT_SHA and GITHUB_SHA. Last, it reads OTEL_RESOURCE_ATTRIBUTES: vcs.ref.head.revision, or service.version when that is a commit SHA.

When the release is a commit featury has seen (the full SHA, or its first 7 characters or more), featury links the error to that commit and looks for the change that likely caused it. Any other release, like 2026.10.21, shows on the error as a label when it is at most 64 letters, digits, dots, underscores, plus signs and hyphens.

featury keeps errors only from the environments your product counts: production, unless you choose more in Settings > Install. The SDK sends FEATURY_ENVIRONMENT, else NODE_ENV, else production.

Send errors from OpenTelemetry

featury/otel needs featury 0.3.11 or newer.

OpenTelemetry is a standard way for a server to send its logs. If yours uses it, add featury's log processor beside the processors you already have:

import { featuryLogProcessor } from "featury/otel";

const processor = featuryLogProcessor("fsk_from_your_server");

It sends your ERROR and FATAL logs to Requests > Errors in featury, where they are grouped like the errors featury.error() sends. It also sends logs named exception or featury.error at any level. It drops info and warning logs, and every other log, before anything leaves your server.

Here it is in a Node server, with the key from your server's secrets:

import { NodeSDK } from "@opentelemetry/sdk-node";
import { featuryLogProcessor } from "featury/otel";

const sdk = new NodeSDK({
    logRecordProcessors: [
        // ...the processors you already use, then:
        featuryLogProcessor(process.env.FEATURY_SERVER_KEY ?? "", {
            user: (a) => (typeof a.userId === "string" ? a.userId : undefined),
            route: (a) => (typeof a.surface === "string" ? a.surface : undefined),
        }),
    ],
});
sdk.start();

featury reads the user from user.id, else enduser.id, and the route from http.route. When your logs use other names, like userId and surface here, pass user and route: what they return replaces those names, and undefined sends none. Send a route like /orders/:id, never a full URL, and send a user id only when your privacy rules allow it.

From each error log, featury reads these fields and nothing else:

| Field | What featury does with it | | --- | --- | | exception.type, exception.message, exception.stacktrace | The error's type, message and stack, cut like featury.error() cuts them. With no message, a text body is the message. | | code.file.path, code.function.name, code.line.number, code.column.number | Where it happened, used only when the stack gives no code lines. | | user.id, enduser.id, http.route, featury.feature | Who hit it, where, and your feature key, as for featury.error(). | | service.name, vcs.ref.head.revision (else service.version), deployment.environment.name (else deployment.environment) | Your service, release and environment, from the resource. | | The log's trace ID | Kept with the error's saved samples. Only people with the Manager role or higher see it. |

An error log needs its time (timeUnixNano, else observedTimeUnixNano), at most 24 hours old and 5 minutes ahead of featury's clock. featury can't read one outside that window.

An error you send both with featury.error() and through OpenTelemetry counts twice, so send each error one way.

Send feedback as an event

When someone gives feedback in your product, like a thumbs-up on search results, you can send it as a log event:

import { logs } from "@opentelemetry/api";

logs.getLogger("search").emit({
    eventName: "featury.feedback",
    attributes: { "featury.feature": "search", "featury.feedback.value": 1, "featury.feedback.scale": 2 },
});

With @opentelemetry/api older than 1.10, import logs from @opentelemetry/api-logs instead.

featury reads only these attributes. A note needs a rating, a choice or some text.

| Attribute | What it is | | --- | --- | | featury.feedback.id | Optional. The note's own UUIDv7, made at most 30 days ago. | | featury.feature | Your feature key. | | featury.item.type, featury.item.id | The thing it's about. A type is up to 32 lower-case letters, digits, _ or -, starts with a letter and needs an id. An id with a colon in it needs a type too. | | featury.feedback.value, featury.feedback.scale | A rating, always both. Scale 2 takes 0 (down) or 1 (up), 5 takes 1 to 5, 10 takes 0 to 10. | | featury.feedback.choice, featury.feedback.text, featury.feedback.kind, featury.feedback.question | As for feedback(). | | featury.check_in.id | The check-in it answers. | | user.id, else enduser.id | Your own user id. |

featury never reads the body of a feedback event, or an area, session, screen, email, follow-up or request link. Send this event only for feedback a person chose to give. featury never turns an ordinary log line into feedback, and it drops gen_ai.evaluation.result events: an AI's score isn't a person's feedback.

How it sends

The processor sends JSON over HTTP to https://widget.featury.app/otlp/v1/logs, about once a second, at most 100 logs and 128 KiB at a time. A log over 128 KiB isn't sent. Up to 1,000 logs and 1 MiB wait in memory; past that the oldest are dropped, because the newest error is the one you need.

When featury is busy (429, 502, 503 or 504) or doesn't answer, the processor tries the same logs up to three times, and waits as long as featury asks before each try. When featury asks it to wait more than 5 seconds, it drops those logs and sends nothing until the wait is over. A refused key (401 or 403) turns it off. A batch featury can't take (400, 413 or 415) is dropped. Each of these prints one warning, with a code like key_refused beside it. forceFlush() and shutdown() wait at most 5 seconds and never fail. Settings > Install shows what arrived today under each server key.

| Option | What it does | | --- | --- | | user, route | Replace user.id, enduser.id and http.route, as above. | | endpoint | Where to send when you host featury yourself: your API_URL plus /widget. | | enabled | false sends nothing and prints nothing, for tests. | | debug | Prints why each log it didn't send was dropped. Default: FEATURY_DEBUG=1. | | waitUntil | Keeps sends alive after a serverless response, as for Featury. | | fetch | Your own fetch, for tests. |

From a Collector

Already running an OpenTelemetry Collector? Point its otlp_http exporter's logs_endpoint at https://widget.featury.app/otlp/v1/logs (before Collector 0.146.0, the exporter is called otlphttp), with the header Authorization: Bearer fsk_... taken from your secret store, never from the URL. Send only ERROR logs, in batches of 100 or fewer. featury takes JSON or protobuf over HTTP, uncompressed or gzip, but not gRPC. Each batch can hold 1 MiB after decompression, 1,000 logs, and 100 errors or feedback events. A 413 means a batch is too large, and sending it again won't help. A 429 or a 503 means wait: the exporter tries again on its own.

Find the original file behind a JavaScript error

The upload command and featury/vite need featury 0.3.11 or newer. Your featury server must also have source map uploads turned on. Installing the package turns nothing on.

Your build turns your code into a few minified JavaScript files. A source map (.map) says which of your files each part came from. After a build that writes .map files, run:

npx featury sourcemaps upload dist

Then an error from those files shows the file it came from in your repository, like src/checkout/pay.ts. It doesn't show a line number: featury keeps file names only. It never uploads your source code, function names or original line numbers.

Before you run it:

  • Put a server key (fsk_...) from Settings > Install in FEATURY_SERVER_KEY, in your build's secrets. Use a separate key for CI. Never put it in a VITE_ variable: Vite copies those into your app's code.
  • Set FEATURY_RELEASE to the release your app sends with its errors, like the commit SHA. The two must match exactly. Uploading doesn't mark a release as deployed.
npx featury sourcemaps upload dist --release v2.4.0 --delete-maps

--delete-maps deletes the .map files the command read, once every upload worked. Without it, they stay in dist, and if you deploy them, anyone can read your source code in them. A failed upload keeps them.

| Option | What it does | | --- | --- | | --release | The release your app sends with its errors. Default: FEATURY_RELEASE, then your deploy platform's commit (the same variables the server SDK reads). 1 to 64 letters, digits, dots, underscores, plus signs or hyphens. | | --root | Your repository's folder. Default: the Git repository you run it in. | | --endpoint | Where to send when you host featury yourself: your API_URL plus /widget. | | --delete-maps | Deletes the maps it read, after every upload worked. | | --help | Shows the options. |

It reads only the folder you name. It never downloads a map, never follows a symlink and skips node_modules. It reads at most 5,000 entries, and 64 MiB per map. It skips maps written inside the JavaScript file (inline) and indexed maps (with sections). Files outside your repository, and secret files like .env, stay unmapped. Maps that name their files with webpack:// URLs, as webpack and Next.js builds do, aren't supported yet.

Two JavaScript files with the same name stop the upload. When several apps share one release, give each output file a name of its own.

Large builds go up in several requests of at most 1 MiB, 500 files or 200,000 positions each. A release holds at most 2,000 files and 4 MiB of index. A busy or unreachable server gets three tries, each with 10 seconds to answer. The command exits with 0 when every file was saved, 1 when something failed and 2 for a wrong option.

With Vite 8

import { featury } from "featury/vite";

export default { plugins: [featury()] };

The plugin runs in vite build, never in the dev server. It reads FEATURY_SERVER_KEY from the build's environment. It takes the release from the first of these it finds:

  1. its release option
  2. VITE_FEATURY_RELEASE in your shell or CI
  3. FEATURY_RELEASE, then your deploy platform's commit

It never uses VITE_FEATURY_RELEASE from a .env file, because Vite reads those files after plugins read their settings. When a .env file holds a value the plugin didn't use, it warns and shows both. To use that value, set it in your shell or CI, or pass featury({ release }).

The plugin puts the release it used in your app, in place of any .env value, so send it with your errors:

import { init } from "featury";

init("fpk_...", { release: import.meta.env.VITE_FEATURY_RELEASE });

With a key and a valid release, and maps off in your config, the plugin turns on hidden maps for the build, uploads the file index and deletes those maps. They're deleted even when the upload fails: then the build goes on, and the warning gives featury's reason, such as uploads being off on the server. It tells you to set build.sourcemap to "hidden", build again and run the upload command with --delete-maps. After a 409 (this release already has a different index), it asks for a new release instead. If a map can't be deleted, the build fails, so those maps can't deploy by mistake.

The plugin finds your repository's folder with Git, so the build needs a checkout with its .git folder. Docker build contexts often leave .git out. Then pass the folder yourself, like featury({ root: "/app" }), or the plugin turns no maps on and warns.

If you turned maps on yourself, the plugin uploads from their .map files and never deletes them. After each build it warns that anyone can read your source code in them once deployed. It can't read inline maps (a map written inside each JavaScript file), so with those it uploads nothing and warns too. Without a key, or without a valid release, it turns no maps on.

It works with one client output folder. For a server, library or watch build, or one with several outputs, it turns no maps on and warns. Then set build.sourcemap to "hidden" and run the upload command with --delete-maps on each output folder. Its options are release, endpoint and root (as for the command) and enabled: false, which turns it off.

Export or erase one person's data

await featury.exportPerson({ email: "[email protected]" });

This returns their feedback, saved error examples, support conversations and usage data: their stored usage profiles, linked browser ids, visits and events. Support conversations match only an address the person left, or a stored link from their feedback. A conversation that names nobody can't be found by user id.

Pass exactly one of user, anonymousId or email. An email finds the notes with that address. It also follows a user or browser id to the person's other notes and saved error examples, but only when the id comes from a usage profile with that email, or from a note your server or a signed-in browser sent with that address. A note where someone typed the address is found itself and leads nowhere else. The errors your server sends carry only user, so an email reaches them only through such an id. Call again with user to reach that user's notes and errors, and with anonymousId for each browser id you know. The same rule applies to exports.

A browser id brings its visits, notes and error examples only when they belong to no account or to this person: a note or visit another signed-in user made from the same browser stays. A browser id linked to a matched profile also brings that browser's notes and error examples sent without a user id. One found only on a note covers its anonymous visits and events, never the account it is linked to, that account's visits or the link itself.

Both calls share a limit of 100 per key per UTC day, and each goes in your audit log.

Exports are bounded: 10,000 notes, 10,000 saved error examples, and up to 1,000 support conversations with 100 messages each and 8 MiB of encoded support data. truncated, errorSamplesTruncated, supportConversationsTruncated and each conversation's messagesTruncated say which parts are incomplete. Usage data sits in tracker: up to 1,000 profiles, 2,000 browser ids, the newest 1,000 visits and 10,000 events from the newest visits back, within 8 MiB of encoded usage data. profilesTruncated, aliasesTruncated, sessionsTruncated, eventsTruncated and trackerTruncated say which parts are incomplete. For a person with more than 50,000 visits plus events, the erase limit, the export reads no visits or events, and sessionsTruncated, eventsTruncated and trackerTruncated are all true. These calls have no continuation yet; deleting data is not a way to fetch the next part.

If you know their email and user id, send both in separate calls:

await featury.erasePerson({ email: "[email protected]" });
await featury.erasePerson({ user: "u_42" });

Erasing deletes their feedback, including request evidence, their saved error examples, their linked support conversations with their messages, and their usage profiles, linked browser ids, visits and events. It returns erased, errorSamples, errorPeople and supportConversations counts, and tracker counts of users, aliases, sessions, events and companies, including zeroes. These counts say what this call deleted, not that every record for the person was found. rollupsPending is true when daily totals, which name nobody, still count the erased visits until the nightly update recalculates them. Delayed feedback and contact writes using erased identities are refused for 30 days. Erasure can't be undone. A chat someone on the team opened in Ask from that person's question stays separate.

One erase deletes up to 50,000 stored visits plus events and 2,000 notes. Above either, the call throws a FeaturyError with status 409 and erases nothing. Ask featury support to erase that person for you.

Serverless

Call await featury.flush() before your function returns. On Cloudflare Workers, pass waitUntil instead:

import { env, waitUntil } from "cloudflare:workers";

const featury = new Featury({ key: env.FEATURY_SERVER_KEY, waitUntil });

When a long-running server stops, call await featury.shutdown().

Read and act with a personal key

FeaturyApi takes a personal API key from your account page in featury and acts as you:

import { FeaturyApi } from "featury/server";

const api = new FeaturyApi(process.env.FEATURY_PERSONAL_KEY ?? "", {
    workspace: "acme",
    project: "app",
});
const page = await api.feedback.list({ feature: "search" });

It also has feedback.get, feedback.deleteHelp, feedback.import, featureKeys.link, people.find, people.export, people.erase, requests.create and requests.get.

The users methods read the people who use your product: users.state, users.live, users.list, users.get and users.activity. people.find shows what an export or erase would reach, without exporting or erasing anything.

const users = await api.users.list({ q: "acme" });

These six need MANAGER+. They share a limit of 60 calls a minute per person with the Users page in the app and with MCP.

await api.feedback.deleteHelp({ feedbackId: "019a0744-e200-7000-8000-000000000001" });

This permanently deletes one help rating: a help-page vote with its comment, or a person's rating of a support answer, including its count and request evidence. A FAQ page already made from it stays. It needs MANAGER+ and accepts only feedback with both source and target HELP. The answer is { deleted: true }, or { deleted: false } when the rating was already absent. It can't be undone.

In tests

With no key, or with enabled: false, nothing is sent. feedback() and error() still return an id, and beforeSend sees every note (beforeSendError every error), so a test can keep them:

import { Featury, type ServerFeedback } from "featury/server";

const notes: ServerFeedback[] = [];
const featury = new Featury({
    enabled: false,
    beforeSend: (note) => {
        notes.push(note);
        return note;
    },
});

When a note or an error is dropped

Pass debug: true (or set FEATURY_DEBUG=1), and the SDK prints why each dropped note or error was dropped and how to fix it. When featury refuses a whole batch, the SDK always prints why, drops that batch and keeps sending the rest.

Limits

| What | Limit | | --- | --- | | Notes in one batch | 100 | | One batch | 1 MiB | | Notes waiting to be sent | 1,000; past that the oldest is dropped | | Server text sent to /signals | 5,000 characters | | Browser, help, support, OpenTelemetry and imported text | 2,000 characters | | choice | 64 characters | | item | 256 characters, no spaces, no "@" | | A note's id | made at most 30 days ago | | Notes per product per day | 20,000 | | Errors in one batch | 100 | | Errors waiting to be sent | 1,000; past that the oldest is dropped | | An error's message | 1,000 characters; the SDK cuts the rest | | An error's stack | 8 KiB; the SDK keeps the whole lines that fit | | An error's id | made at most 1 day ago | | New error ids per product per day | 20,000 | | New kinds of error per day | 100 from your servers; past that they count together in one group |