Browser RUM and session replay for ClickStack and ClickHouse Observability,
built on the upstream OpenTelemetry browser SDK. init sets up the SDK with a fixed set of
instrumentations and records a session replay. Apps that set up the SDK themselves use only
startReplay (Using the upstream SDK directly).
If you're migrating from @hyperdx/browser please follow the migration guide.
npm install @clickhouse/clickstack-browserThe package is ESM only. @opentelemetry/api and @opentelemetry/browser-sdk are peer
dependencies, which npm and pnpm install automatically. With Yarn, add them yourself:
yarn add @clickhouse/clickstack-browser @opentelemetry/api@~1.9.1 @opentelemetry/browser-sdk@^0.6.0Keep a single copy of @opentelemetry/api at 1.9.x; npm ls @opentelemetry/api lists them. A copy
at another version, including 1.10 from OpenTelemetry JS 3.x, makes registration fail silently, so
the SDK's telemetry or the spans from your own trace.getTracer() calls go missing.
Pages without a bundler load dist/clickstack.iife.js, which defines the global ClickStack. Call
ClickStack.init from a file of its own, so a Content Security Policy without 'unsafe-inline'
allows it:
<script
src="https://cdn.jsdelivr.net/npm/@clickhouse/clickstack-browser@1.0.0/dist/clickstack.iife.js"
integrity="sha384-HASH_FROM_THE_RELEASE_NOTES"
crossorigin="anonymous"
></script>
<script src="/telemetry.js"></script>// telemetry.js
ClickStack.init({
endpoint: "https://example-collector.clickhouse.com:4318",
apiKey: "<YOUR_INGESTION_API_KEY>",
serviceName: "example-app",
});Use an exact version with the integrity hash from that version's release notes. The global also
has ClickStack.otel (trace, context, logs) for your own spans and log records.
import { init } from "@clickhouse/clickstack-browser";
init({
endpoint: "https://example-collector.clickhouse.com:4318",
apiKey: "<YOUR_INGESTION_API_KEY>",
serviceName: "example-app",
traceOrigins: ["https://example-api.clickhouse.com"],
});endpoint is the OTLP HTTP address of your OpenTelemetry collector (port 4318), the same value as
OTEL_EXPORTER_OTLP_ENDPOINT, without /v1/traces. With ClickStack, it is your service's collector URL.
Call init once per page load, at the top level of your entry module (instrumentation-client.ts
in Next.js, src/main.ts in Vite), not in a component effect.
| Option | Default | Meaning |
|---|---|---|
endpoint |
Required | OTLP/HTTP base URL of the collector, or a same-origin path such as /otel that your server forwards. /v1/traces and /v1/logs are appended. Not needed when both signal endpoints are set. |
tracesEndpoint |
None | Full URL for traces, used as is, like OTEL_EXPORTER_OTLP_TRACES_ENDPOINT. Replaces the traces URL built from endpoint. |
logsEndpoint |
None | Full URL for logs and replay, used as is, like OTEL_EXPORTER_OTLP_LOGS_ENDPOINT. Replaces the logs URL built from endpoint. |
serviceName |
Required | service.name of all telemetry. |
serviceVersion |
None | service.version, such as 2.0.0 or a commit SHA, to compare releases. |
environment |
None | deployment.environment.name, such as production or staging. |
resourceAttributes |
None | Further resource attributes of all telemetry, replay records included, such as service.namespace. The options above win over the same keys. |
apiKey |
None | Ingestion key, sent as the authorization header. Anyone who loads the page can read it; to keep it private, send to a same-origin path whose server adds the header. |
traceOrigins |
None | Backends whose fetch and XHR requests carry trace context (Trace propagation and CORS). A string matches every URL under that origin; a RegExp is matched against the URL. |
replay |
true |
false turns session replay off. |
maskAllText |
true |
Masks all text in the replay. With false, elements with the rr-mask class stay masked (Privacy). |
maskAllInputs |
true |
Masks the values of all inputs, text areas and selects. With false, only passwords are masked. |
console |
'error' |
Console level recorded as log events: 'error', 'warn', 'info' (with console.log), 'debug', or false. |
sanitizeUrl |
None | Rewrites URLs before they are recorded, for example to drop tokens from query strings (Privacy). |
Without a serviceName and an http(s) endpoint (or both signal endpoints), init logs an error
and starts nothing, and a later valid call still starts. Unknown options are ignored with a warning.
The rest is fixed:
- Instrumentations: fetch, XHR, errors, failed resource loads, navigation, user action, web vitals, navigation timing, resource timing, long task and console.
- Sessions last at most 4 hours and end after 15 minutes without telemetry. They are stored in
localStorageand shared by the tabs of the origin. - Nothing is sampled.
- Diagnostics go to the browser console at WARN level.
Other functions:
getSessionId()returns the currentsession.id, ornullbeforeinithas started.pauseReplay()andresumeReplay()stop and restart replay. RUM continues.setUser(user)addsuser.id,user.email,user.name,user.full_nameanduser.rolesto later spans and log records;setUser(null)removes them. Set only what your privacy policy allows.setAttributes(attributes)adds attributes, such as a tenant or a plan, to later spans and log records, replacing the previous set;setAttributes(null)removes them. Attributes the SDK orsetUsersets win. Prefer namespaced keys, such asapp.team.name.startNewSession()ends the current session and starts one with a newsession.id. On sign-out, call it withsetUser(null), so the next user on the device gets a session of their own. It does nothing beforeinithas started.recordException(error, attributes?)records an error your code caught (Errors).shutdown()sends what is buffered and stops everything for the rest of the page.
Uncaught errors, unhandled rejections, and failed loads of images, scripts, stylesheets and media
are recorded as exception log events. console.error calls are console events, with bodies
formatted as the browser console shows them. When an argument is an error, the event also carries
its exception.* attributes. React reports errors caught by error boundaries this way, with
console.error(error), so a boundary needs no extra call.
Record errors that never reach the console with recordException, such as in your own
try/catch, Vue's app.config.errorHandler, a React onCaughtError override, or any page with
console: false:
import { recordException } from "@clickhouse/clickstack-browser";
try {
await checkout();
} catch (error) {
recordException(error, { "app.operation": "checkout" });
}- Each event has
exception.type,exception.messageandexception.stacktrace, the browser's raw stack text. An error'scausechain, up to five deep, follows the stack asCaused by:sections. A thrown value that is not an error is recorded as JSON text inexception.message. - Failed loads have the type
ResourceLoadErrorand the URL inurl.full. - Errors thrown by a script from another origin, such as a CDN, arrive as
Script error.with no message or stack, unless its<script>tag hascrossorigin="anonymous"and the server sendsAccess-Control-Allow-Origin. - Calling
recordExceptionfor an error that is also passed toconsole.errorrecords it twice. - Inside an active span,
recordExceptionlinks the event to it. Browsers lose the active span after anawait.
By default, replay masks all text and the values of every input (hidden inputs included),
textarea and select. With maskAllText: false, give sensitive elements rrweb's rr-mask class
to keep their text masked.
- Not masked: checkbox and radio state, and attributes such as
placeholder,title,alt,aria-labeland link URLs. Give sensitive elements rrweb'srr-blockclass to record them as empty boxes. - Not recorded: canvas contents, web fonts and image data.
- URLs keep their query strings unless
sanitizeUrlrewrites them. It applies to every URL this package records, except URLs inside the replayed page content, stack traces and console messages. If it throws or returns a non-string, the URL is recorded as empty.
Requests to the page's origin and to traceOrigins carry the traceparent, tracestate and
baggage headers, so backend traces join the browser's. Each backend in traceOrigins must allow
these headers in CORS (Access-Control-Allow-Headers), or its requests fail.
A collector that the page sends to directly must allow the page's origin. ClickStack's collector allows all origins; your own collector needs:
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
cors:
allowed_origins: ["https://example-app.clickhouse.com"]
allowed_headers: ["*"]import { startReplay } from "@clickhouse/clickstack-browser/replay";
import { FetchInstrumentation } from "@opentelemetry/browser-instrumentation/experimental/fetch";
import { startBrowserSdk } from "@opentelemetry/browser-sdk";
import {
createDefaultSessionIdGenerator,
createLocalStorageSessionStore,
createSessionLogRecordProcessor,
createSessionManager,
createSessionSpanProcessor,
} from "@opentelemetry/browser-sdk/session";
const endpoint = "https://example-collector.clickhouse.com:4318";
const tracesUrl = `${endpoint}/v1/traces`;
const logsUrl = `${endpoint}/v1/logs`;
const sessionManager = createSessionManager({
sessionIdGenerator: createDefaultSessionIdGenerator(),
sessionStore: createLocalStorageSessionStore(),
maxDuration: 4 * 60 * 60,
inactivityTimeout: 15 * 60,
});
await sessionManager.start(); // before anything emits, or a second session is created
startBrowserSdk({
serviceName: "example-app",
// A signal with its own processors ignores the root exportConfig.
logs: {
processors: [createSessionLogRecordProcessor(sessionManager)],
exportConfig: { url: logsUrl },
},
traces: {
processors: [createSessionSpanProcessor(sessionManager)],
exportConfig: { url: tracesUrl },
},
// enabled: false lets the SDK enable them once its providers exist.
instrumentations: [
new FetchInstrumentation({
ignoreUrls: [tracesUrl, logsUrl],
enabled: false,
}),
],
});
const replay = startReplay({
sessionManager,
endpoint,
serviceName: "example-app",
});serviceNameandresourceAttributesmust match the SDK's, because ClickStack finds a session's replay by itsservice.name.- Add the logs URL to the
ignoreUrlsof the fetch, XHR and resource timing instrumentations, or every replay export is recorded as telemetry. startReplaytakesmaskAllText,maskAllInputsandsanitizeUrlasinitdoes. The navigation timing and resource timing instrumentations have nosanitizeUrl; rewrite theirurl.fullin a log record processor.startReplayreturns{ pause, resume, shutdown }.setUserworks withinitonly.
- Resource:
service.name,service.version,deployment.environment.name,telemetry.sdk.*,telemetry.distro.name(clickstack-browser),telemetry.distro.version,user_agent.original,browser.*, andresourceAttributes. - Every span and log record:
session.id,browser.document.url.full,user.*aftersetUser, and the attributes fromsetAttributes. - Log events:
browser.navigation,browser.user_action.click,browser.web_vital,browser.console,exception,browser.navigation_timingandbrowser.resource_timing. Fetch and XHR requests and long tasks are spans. - Replay: gzip-compressed log records with the scope
rum.rr-web, sent to the logs URL. A collector between the page and ClickStack must keep their attributes unchanged.
initreturns at once and starts in the background, so telemetry from the same tick, such as an immediatefetch, is not captured. Calls tosetUser,pauseReplayandresumeReplaybeforeinitare kept. Only the firstiniton a page counts. Calling it later, for example after fetching runtime configuration, still records the page's navigation, navigation timing and resource timing.- Telemetry is sent in batches, and when the page is hidden. Browsers limit what a closing page can still send to 64 KiB in total, so the last part of a long replay can be lost when the user leaves.
shutdown()is final: a lateriniton the page is ignored. In React StrictMode, aninitin an effect withshutdownin its cleanup ends telemetry for the page.
- Traces and logs work with every ClickStack version. Session replay needs ClickStack 2.37.0 or later.
- The Sessions list and replay playback need a ClickStack release that reads
session.id. Earlier releases store the data but don't list these sessions.
- Browsers: Baseline Widely available (ES2022). No
eval, so a strict Content Security Policy works;connect-srcmust allow the endpoint. - OpenTelemetry: the 1.x line supports OpenTelemetry JS 2.x:
@opentelemetry/api>=1.9.1 <1.10.0and@opentelemetry/browser-sdk^0.6.0.
The package is ESM only, like the OpenTelemetry browser packages it uses. Vitest loads it as is while Jest with CommonJS does not, so map the package to a stub in the Jest config:
// jest.config.js
moduleNameMapper: {
"^@clickhouse/clickstack-browser$": "<rootDir>/test/clickstack-browser.js",
},// test/clickstack-browser.js
const noop = () => undefined;
module.exports = {
init: noop,
setUser: noop,
setAttributes: noop,
getSessionId: () => null,
startNewSession: noop,
recordException: noop,
pauseReplay: noop,
resumeReplay: noop,
shutdown: () => Promise.resolve(),
};