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 featuryThe 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 featuryThen 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 revisionupdate 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 outThat 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 distThen 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 inFEATURY_SERVER_KEY, in your build's secrets. Use a separate key for CI. Never put it in aVITE_variable: Vite copies those into your app's code. - Set
FEATURY_RELEASEto 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:
- its
releaseoption VITE_FEATURY_RELEASEin your shell or CIFEATURY_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 |
