@extension-report/js
v0.6.2
Published
Modular owner metrics SDK for browser extensions with remotely controlled adoption, engagement and reliability instrumentation.
Maintainers
Readme
@extension-report/js
Owner metrics SDK for https://extension.report.
MV3-native and zero dependencies. The 0.6 architecture separates core, configuration, transport
and instrumentation modules while keeping every remotely reactivatable capability in the store
artifact. A 20 KB Brotli size-limit budget prevents silent growth.
The normative browser/framework support matrix and the full-SDK decision are in https://github.com/Amorem/extension.report/blob/main/docs/sdk-compatibility.md.
Install
pnpm add @extension-report/[email protected]For store builds, pin the exact version in package.json ("@extension-report/js": "0.6.2",
not ^0.6.2) and run npx @extension-report/[email protected] against the built manifest before every
submission. If you use a companion package, keep every @extension-report/* SDK package on the
same version. The store-validation runbook is in the repository docs:
https://github.com/Amorem/extension.report/blob/main/docs/sdk-store-validation.md.
Quick start
import {
connectExtensionReportUiSession,
initExtensionReport,
} from "@extension-report/js";
const sdk = initExtensionReport({
projectPublicKey: "pk_er_...",
debug: { log: false, dryRun: false },
});That single background/service-worker call auto-registers every standard browser signal the SDK can observe safely. Missing browser APIs or permissions are ignored.
Initialize at top level in the background service worker. MV3 can miss events when listeners are
registered after the first service-worker turn, and the doctor cannot detect that timing issue.
If a browser runtime does not expose navigator.locks, initialize the SDK in that background
context only; multi-context queue writes are not supported without a cross-context lock.
Test-only helpers live under @extension-report/js/testing; they are not part of the integration
API and may change outside the public SDK contract.
Standard signals
- Lifecycle:
extension_installed,extension_updated,extension_update_available,extension_started,extension_heartbeat - Extension opens:
extension_ui_opened,extension_ui_closed,extension_options_opened,extension_side_panel_opened - Toolbar adoption:
toolbar_pin_state_seen,toolbar_pin_state_changed - Environment diagnostics:
environment_state_seen(SDK, extension, install type, manifest, browser, OS, locale and timezone snapshot) - Identity:
user_identify - Context menus:
context_menu_clicked - Keyboard shortcuts:
keyboard_shortcut_configured,keyboard_shortcut_missing,keyboard_shortcut_used - Omnibox:
omnibox_session_started,omnibox_input_entered(per-keystroke input is aggregated into theomnibox_input_changeddaily counter, not sent as individual events) - Permissions:
permission_requested,permission_granted,permission_declined,permission_removed,host_permission_granted,host_permission_removed - Notifications:
notification_shown,notification_clicked,notification_button_clicked,notification_closed,notification_closed_by_user - Review funnel:
review_prompt_shown,review_prompt_clicked - Errors and custom usage:
sdk_error,custom_event
Helpful calls
const sdk = initExtensionReport({
projectPublicKey: "pk_er_...",
});
await sdk.popup.opened();
await sdk.review.promptShown({ placement: "settings" });
await sdk.review.promptClicked({ placement: "settings" });
const session = connectExtensionReportUiSession({
surface: "popup",
entrypoint: "action_icon",
});
await browser.notifications.create("welcome", {
type: "basic",
title: "Ready",
message: "The extension is configured.",
iconUrl: "/icon.png",
});
void sdk.notifications.shown("welcome", { kind: "setup" }).catch(() => null);
const queue = await sdk.getQueueStatus();
await sdk.identify("user_123", {
entitlement_type: "paid",
entitlement_status: "active",
plan_code: "subscription",
});
await sdk.identify(null, {
entitlement_type: "trial",
entitlement_status: "active",
plan_code: "anonymous_trial",
trial_expires_at: "2026-07-31T23:59:59.000Z",
});
await sdk.trackCustomEvent("rule_created", { source: "popup" });Local daily counters
For high-frequency signals (keystrokes, scroll, polling loops), sending one event per
occurrence is waste. sdk.count(name) increments a local counter instead; counters are
delivered once per UTC day as a single usage_agg_daily event and feed the "Top actions"
usage breakdown in the dashboard:
await sdk.count("pages_scanned");
await sdk.count("rows_processed", 25);Use identify(...) for user/account traits, trackCustomEvent(...) when each occurrence
matters (funnels, feature adoption), and count(...) when only the daily volume does.
identify(...) queues immediately when the user id or traits change, then at most once per
24 h for the same state. The queued event follows the SDK's normal durable flush path.
The user id can be null for anonymous installations; traits such as plan, license,
trial state, workspace, or rollout cohort still describe the current installation state.
Use custom events for actions such as upgrade clicks, not for the state itself.
Most signals are automatic. Keep these explicit helpers for two concrete browser gaps:
- If your manifest has
"action": { "default_popup": "popup.html" }, Chrome opens the popup directly and does not firechrome.action.onClickedin the background. PreferconnectExtensionReportUiSession(...)from the popup page; the background SDK emitsextension_ui_opened, thenextension_ui_closedwithduration_mswhen the popup port disconnects. Usesdk.popup.opened()only for open-only tracking. - If you need
notification_shown, keep product-critical notifications on the native browser API, then callsdk.notifications.shown(...)after the native call succeeds. Usesdk.notifications.show(...)only for simple cases where a wrapper is acceptable. Clicked, button-clicked, closed, and closed-by-user are listened to automatically.
instrumentation.automatic: false is for non-background contexts. Use it when importing the SDK in
a popup, options page, side panel, or content script so that only the focused helper runs there. The
full lifecycle and browser listeners should stay registered once in the background service worker.
Permissions
Manifest permissions are automatic. On startup the SDK calls chrome.permissions.getAll(), compares
the result with the last local snapshot, and emits only changes:
- first observed permissions become one
permission_state_seenbaseline, not grant events, - newly added permissions become
permission_granted/host_permission_granted, - removed permissions become
permission_removed/host_permission_removed, - unchanged snapshots are ignored, so service-worker restarts do not spam events.
Use sdk.permissions.request(...) only for optional permissions requested during product usage:
const granted = await sdk.permissions.request({
permissions: ["tabs"],
origins: ["https://example.com/*"],
reason: "capture-current-tab",
});That pattern is useful when an extension wants fewer install-time warnings and asks for access only
when the user enables a feature: tab capture, screenshots, Gmail/Notion/Jira integrations, a
specific customer domain, or site-specific automation. The wrapper is needed only to measure
permission_requested and permission_declined; Chrome broadcasts grants/removals automatically.
Advanced contexts
- Notification clicks, button clicks, and closes are automatic. For
notification_shown, preferbrowser.notifications.create(...)followed by best-effortsdk.notifications.shown(...)so telemetry cannot block a product-critical notification. - Content scripts and injected UIs are not visible to background listeners. Send a message to the
background or use
trackCustomEvent(...)for product actions such assettings_saved,rule_created, orexport_completed.
For unusual integrations you can opt out of the auto setup:
const sdk = initExtensionReport({
projectPublicKey: "pk_er_...",
instrumentation: { automatic: false },
});Remotely controlled modules
SDK 0.6 groups automatic observation by product outcome rather than exposing one remote switch per browser API:
- Core — identity, lifecycle, queue, delivery and remote config. The dashboard exposes one emergency stop for the complete SDK.
- Adoption — pin state, permissions and shortcut readiness.
- Engagement — UI sessions, menus, shortcut usage, omnibox, notifications, review prompts, custom events and daily counters.
- Reliability —
off, errors only, or errors plus network diagnostics.
The dashboard can publish modes, deterministic rollout percentages and context audiences without a store release. Local options remain the ceiling: remote config cannot enable a group explicitly disabled by the extension or add a missing manifest capability.
const sdk = initExtensionReport({
projectPublicKey: "pk_er_...",
instrumentation: {
adoption: true,
engagement: true,
reliability: "full",
},
});
const status = await sdk.getRemoteModuleStatus();
// { configVersion, modules: { core, adoption, engagement, reliability } }Active MV3 clients apply a new release on the next event/flush. A dormant service worker applies it on a later wake or cache refresh; the dashboard reports convergence instead of claiming impossible always-on real-time delivery.
User opt-out (consent)
Tracking is enabled by default. Give your users a way to opt out — the Chrome Web Store user-data policies and GDPR expect one for EU audiences:
// settings page (via a message to the background, where the SDK lives)
await sdk.setTrackingEnabled(false); // persists, purges the local queue, blocks all events
await sdk.isTrackingEnabled(); // read the current state for the toggle
await sdk.setTrackingEnabled(true); // opt back inDisabling is local to the installation: it survives service worker restarts and extension
updates. The SDK sends one minimal telemetry_opted_out consent marker, then blocks product
events until the user opts back in; opting back in sends telemetry_opted_in.
For opt-in markets, initialize with initialConsent: "denied" before registering automatic
instrumentation. The SDK persists that default, blocks telemetry until
setTrackingEnabled(true), and replays the first extension_installed event with its original
timestamp when consent is granted within the server attribution window.
Debug and dry run
const sdk = initExtensionReport({
projectPublicKey: "pk_er_...",
debug: {
log: true,
dryRun: true,
},
});
await sdk.trackCustomEvent("debug_probe");
console.log(await sdk.getQueueStatus());debug.logprints local SDK diagnostics in addition to any remote debug flag.debug.dryRunbuilds and drains batches locally without sending network requests.getQueueStatus()reports queued events, queue date bounds, backoff, server-dropped, stale, and locally evicted events, pending daily counters, and dry-run state.
Network diagnostics
sdk.trackError(error) reports handled errors. With automatic instrumentation enabled, the SDK also
captures uncaught errors and wraps fetch so a later error report can include request method,
status, duration, and whether the failure was an HTTP response or a network error.
The defaults are privacy-safe:
- request URLs keep origin and path only; query strings and hashes are removed,
- content-script runtime URLs keep only the visited page origin,
- response body previews are not read or sent,
- repeated identical error fingerprints are suppressed for 24 h.
If your extension needs richer diagnostics, opt in explicitly:
const sdk = initExtensionReport({
projectPublicKey: "pk_er_...",
instrumentation: {
errorDiagnostics: {
includeQueryStrings: true,
includeResponseBodies: true,
maxResponseBodyBytes: 1000,
includeContentScriptUrls: "origin",
},
},
});Even with includeQueryStrings: true, sensitive query keys such as token, key, secret,
authorization, auth, password, and session are redacted. Disable the fetch wrapper entirely
with instrumentation: { networkDiagnostics: false }.
Chrome Web Store data disclosure
At minimum, disclose extension usage analytics and diagnostics in your Chrome Web Store Data Usage
form, and disclose any user identifier you pass through identify(...). You should not need to
declare browsing history when using the default SDK settings: content-script errors keep only the
page origin, not the full visited URL. If you opt in to full content-script URLs or response bodies,
update your disclosure and privacy policy accordingly.
Manifest requirements
{
"permissions": ["storage", "alarms"],
"host_permissions": ["https://extension.report/*"],
"background": { "service_worker": "background.js", "type": "module" }
}Add "contextMenus" only when you instrument context menu clicks. Add "notifications" only
when your extension already shows notifications. Add "commands" or "omnibox" manifest keys
only when your extension exposes those workflows.
Notes
- The SDK sends batches to
/api/v2/eventswith retry/backoff. - SDK 0.3.8+ emits one compact
environment_state_seenstate event instead of the deprecatedextension_version_seen,browser_version_seen,sdk_version_seen, andmanifest_version_seenevents. It is sent only when the environment snapshot changes or after the state-signal resend window, so service-worker wakes do not multiply version rows. Screen and device fields are not part of this event; they stay in the batch context when the runtime can observe them, including UI sessions opened throughconnectExtensionReportUiSession(...). - SDK 0.3.11+ exposes
sdk.trackError(error). SDK 0.3.13+ keeps error diagnostics privacy-safe by default: fetch query strings are stripped, response bodies are off unless explicitly opted in, and content-script page URLs are reduced to origin. - SDK 0.3.9+ records the first permission snapshot as state only. It does not count already installed manifest permissions as freshly granted optional permissions. Non-empty permission snapshots still feed configuration-state metrics and the latest active permission list shown in installation details.
- SDK 0.3.10+ snapshots the lightweight event context at enqueue time: extension version, SDK version, manifest version, browser, OS, locale, timezone and languages. Queued events therefore keep the version/environment they actually occurred under even when they flush after an extension update. Screen and device stay in the current batch context.
- Automatic signals and custom events debounce their network flush
(
batching.flushDebounceMs, default 2000 ms, remote-config keyflush_debounce_ms,0= immediate): a burst becomes one batched request. Events are persisted locally before the window, so a dying service worker loses nothing — the flush alarm delivers on the next wake. Explicittrack()andflush()remain immediate and strict. - The SDK reads remote config from
/api/v2/config. SDK 0.5.0+ exposes feature flags withawait sdk.getFlag("review_prompt", false)and advanced config values withawait sdk.getConfigValue("rollout.cohort", "default"). SDK 0.5.1+ flags can be booleans or rollouts such as{ "beta_panel": { "rollout": 0.25 } }. 0.5.2+ rollout objects can also include small context audiences, for example{ "firefox_beta": { "rollout": 0.5, "match": { "browser_name": "firefox", "min_extension_version": "2.1.0" } } }. An installation with an unknown context value does not match an audience that constrains that field. Event responses includeconfig_version; when it changes, active clients refresh config on the next successful flush instead of waiting for the normal TTL. Projectplanandlimitsare resolved server-side into the public config; SDK-facing limits remain available as top-level keys such asmax_events_per_minute, while server-only limits such asproject_requests_per_minuteare not returned to the browser. - SDK 0.6 adds grouped
modulespolicies. Every batch carries the effectiveconfig_versionand module state so the dashboard can measure active-installation convergence and safe rollback. - After the first successful event batch, the API returns an uninstall token. SDK 0.3.13+ stores it,
calls
chrome.runtime.setUninstallURL, and refreshes the URL on service-worker wakes so thelslast-seen parameter stays current. - Events are written to local extension storage before network delivery. Helper methods are best-effort, so a temporary extension.report outage does not break extension behavior.
flush()is intentionally strict for diagnostics; explicit calls reject on ingestion failure while queued events remain stored locally for retry.- If your UI or content scripts run in another context, send a message to the background to call the SDK there.
- Delivery is fail-safe: batches rejected as invalid or oversized (4xx) are dropped and
counted in
getQueueStatus().droppedEventsinstead of blocking the queue; an unknown project key (rotated without shipping a new build) drops and rechecks once a day. Locally stale events older than the server attribution window are counted separately ingetQueueStatus().staleEvents. - SDK 0.4.0+ uses deterministic remote-config sampling per installation and event name, exposes
local queue evictions through
getQueueStatus().evictedEvents, bounds daily counter cardinality with anotherbucket, micro-batches debounced queue writes, and uses Web Locks when available to coordinate queue writes across extension contexts. max_events_per_minuteis enforced in memory for the current service-worker lifetime. MV3 can reset that window on wake; the server still enforces ingestion rate limits.- Reliability mode
errorsdisables fetch diagnostics collection without an extension update. The wrapper remains installed but becomes pass-through, sofullcan reactivate it later. - SDK 0.4.0 removes the deprecated
trackRawalias andapp.typeoption.track(...)is strict and accepts only standard event names; usetrackCustomEvent(...)for product-specific events. The backend treats all SDK events as extension events. - SDK 0.5.0 adds remote-config
flagsfor product toggles without a store release; SDK 0.5.1 adds percentage rollouts and faster active-client convergence throughconfig_version. - SDK 0.5.0 captures
chrome.management.getSelf().installTypewhen available. Dashboard product metrics excludedevelopmentinstalls by default; the Report has an include-dev toggle for local QA. - WXT users can install
@extension-report/wxtfor the WXT module (modules: ["@extension-report/wxt/module"]) and WXT-named background/UI session helpers. - Plasmo users can install
@extension-report/plasmofor the same thin adapter pattern with Plasmo-named helpers. - Run
npx @extension-report/[email protected] --public-key pk_er_... --manifest manifest.jsonto check manifest permissions, remote config reachability, and a non-ingesting event dry-run. - The package is ESM-only (
type: "module"), which every extension bundler (Vite/WXT/Plasmo/webpack) consumes natively. - In Node/SSR/build contexts with no extension runtime and no page globals, the SDK returns an
inert no-op instance by design. In that mode
isTrackingEnabled()resolves tofalse.
License
MIT — see LICENSE.
