Skip to main content

Frontend Setup: JavaScript/TypeScript

Enterprise Configuration

This guide covers frontend instrumentation for Sailfish Enterprise. Key differences from local development:

  • API key: Use your Enterprise API key (shown as "<ApiKey />" below -- auto-replaced when you're logged in)
  • Backend API: Do not set backendApi -- the SDK defaults to the Sailfish cloud endpoint automatically
  • Service identifier: Use the <org>/<repo>/<path-to-this-file> format (e.g., acme-corp/web-app/src/index.tsx)
  • No environment gating: Enterprise telemetry runs in all environments (staging, production)

If you connected GitHub and received Auto-Installation PRs, the API key, service identifier, and graphql endpoint are already configured for you.

This guide walks you through instrumenting a frontend application with the SF Veritas Recorder SDK for Sailfish Enterprise.

Getting Your API Key

  1. Open the Sailfish dashboard
  2. Log in with your enterprise email
  3. Navigate to Settings > Configuration
  4. Copy your company's API key

Installation

Install the Recorder package:

npm install @sailfish-ai/recorder

Or with yarn:

yarn add @sailfish-ai/recorder
No build step?

If your frontend doesn't have a bundler (static HTML, CMS embeds, Shopify / WordPress / Webflow, server-rendered pages), see the CDN / script-tag install guide for a zero-dependency <script>-tag install.

Basic Setup

Add the following to your application's entry point (e.g., index.tsx, main.tsx):

import { initRecorder } from '@sailfish-ai/recorder';

initRecorder({
apiKey: "<see-api-key-from-your-account-settings-page>",
serviceIdentifier: 'acme-corp/web-app/src/index.tsx', // Format: <org>/<repo>/<path-to-this-file>
serviceVersion: '1.0.0',
// Your company's allowed domains — add more here if you have additional internal hosts.
domainsToPropagateHeaderTo: ["*"],
});

// Your application code continues below...
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';

const root = ReactDOM.createRoot(document.getElementById('root')!);
root.render(<App />);

Vanilla JavaScript

import { initRecorder } from '@sailfish-ai/recorder';

initRecorder({
apiKey: "<see-api-key-from-your-account-settings-page>",
serviceIdentifier: 'acme-corp/web-app/src/main.js', // Format: <org>/<repo>/<path-to-this-file>
serviceVersion: '1.0.0',
});

// Your application code...
console.log('Application started'); // This will appear in Sailfish

Configuration Options

Basic Options

OptionTypeDefaultDescription
apiKeystringRequiredYour Sailfish Enterprise API key

Privacy & Masking

By default, any DOM element with the class sailfishSanitize (and all of its descendants) is masked: visible text is replaced with * in the recording, and form input values entered anywhere under that subtree are sanitized in the browser before they are sent to Sailfish — so sensitive data never leaves the user's machine. You only need to add the class to the parent element you want to protect; every nested child inherits it automatically. To use a different class name, set maskTextClass in initRecorder.

OptionTypeDefaultDescription
maskTextClassstring"sailfishSanitize"CSS class name; any element with this class (and all its descendants) has visible text replaced with * in recordings, and form input values under the subtree are sanitized before they leave the browser.
import { initRecorder } from '@sailfish-ai/recorder';

initRecorder({
apiKey: "<see-api-key-from-your-account-settings-page>",
serviceIdentifier: 'acme-corp/web-app/src/index.tsx',
serviceVersion: '1.0.0',
maskTextClass: "pii-redact", // overrides default "sailfishSanitize"
});
<!-- Mask the whole subtree by tagging the parent only: -->
<div class="pii-redact">
<p>This text becomes ****</p>
<input value="this value is sanitized" />
</div>

Canvas Recording & Privacy

Enabling canvas recording

Canvas recording is off by default. Turn it on per-app with the recordCanvas init option, or per-company from the Sailfish dashboard (Capture Settings). A value passed to initRecorder wins over the dashboard setting.

import { initRecorder } from '@sailfish-ai/recorder';

initRecorder({
apiKey: "<see-api-key-from-your-account-settings-page>",
serviceIdentifier: 'acme-corp/web-app/src/index.tsx',
serviceVersion: '1.0.0',
recordCanvas: true, // capture <canvas> content: 2D command stream, WebGL/WebGPU video, pixel keyframes
// maskCanvasClass: 'pii-redact', // optional — custom canvas-mask class (see below; default "sailfishSanitize")
});
OptionTypeDefaultDescription
recordCanvasbooleanfalseEnable the pixel-perfect canvas recorder (2D command stream + WebGL/WebGPU video + keyframes).
maskCanvasClassstring"sailfishSanitize"CSS class that excludes a <canvas> (or any ancestor) from recording — see below.
Canvas text is captured unless you mask it

When canvas recording is enabled, Sailfish captures the pixel content of your <canvas> elements. For 2D contexts it additionally captures the canvas draw command stream, which includes the literal string arguments of text draws — every fillText() / strokeText() call. Sailfish automatically redacts recognizable credit-card (Luhn-validated), SSN, and date-of-birth values from the command stream (see Automatic PII redaction below), but any other on-screen text — names, emails, account numbers — is captured verbatim. If a canvas can display sensitive data, mask it.

Controlling what is captured

Canvas recording honors the same privacy selectors as DOM masking (configured via the Privacy & Masking capture settings — maskTextSelector, blockSelector, unmaskSelector, plus the canvas-specific maskCanvasClass / data-sf-mask). Each rule matches the <canvas> element itself or any ancestor:

ControlEffect on a matching canvas
data-sf-mask attribute, the maskCanvasClass class (default sailfishSanitize), or blockSelectorExcluded entirely — no commands, no keyframes, no video. A single op:"masked" placeholder marks it in the session.
maskTextClass / maskTextSelectorText redacted — every fillText() / strokeText() string is masked (****). Keyframes are kept: only the masked text's pixel region is blanked (region-blanking), so the canvas stays pixel-perfect everywhere else. The canvas stays command-only (it never demotes to video). (A WebGL / WebGPU / worker canvas that matches is excluded entirely, since its text is pixels, not commands.)
maskCanvasText (global)Mask all canvas text — redacts drawn text on every recorded canvas. Unset = mirror-DOM: if any DOM text masking (maskTextSelector / maskTextClass) is configured, canvas text is masked too; set false to opt out.
unmaskSelectorOverride — records normally even inside a masked / blocked subtree (mirrors DOM precedence).

Redaction style. By default masked text renders as length-preserving asterisks (Jane****) and masked shapes keep their footprint, recolored to a neutral redaction color — so layout is preserved while content is hidden. Tune it globally with canvasRedactStyle: { text: 'asterisks' | 'bar', shape: 'color' | 'block' | 'remove' }, or per call on maskCanvasDraws() (below).

<!-- Exclude a canvas (and everything under a wrapper): -->
<canvas data-sf-mask></canvas>
<div data-sf-mask><canvas><!-- not recorded --></canvas></div>

<!-- Or by class (default "sailfishSanitize", or your maskCanvasClass): -->
<canvas class="sailfishSanitize"></canvas>

Exclusion is a hard guarantee across every capture layer (2D/WebGL command stream, keyframes, and video) — not a best-effort filter.

Masking specific drawings within a canvas — maskCanvasDraws()

The selectors above operate on the whole <canvas> element — that is the only granularity CSS selectors can offer, because a canvas is drawn imperatively (fillRect, moveTo/lineTo/fill, fillText) into a single element with no sub-elements for a selector to target. To redact one shape, region, geometry, or label inside a canvas, wrap those draw calls in maskCanvasDraws(). Wrapped content is redacted in place — shapes keep their footprint but are recolored to a neutral redaction color, and text becomes length-preserving asterisks — while the masked pixel regions are blanked on every keyframe so the original never leaves the browser. The rest of the canvas stays pixel-perfect; everything outside the wrapper records normally:

import { initRecorder, maskCanvasDraws } from '@sailfish-ai/recorder';

function render(ctx) {
// Recorded normally:
ctx.fillStyle = '#0a0';
ctx.fillRect(0, 0, 800, 600);

// Redacted in place — the rectangle keeps its position/size but is recolored to
// the redaction color, and the label becomes asterisks:
maskCanvasDraws(ctx, () => {
ctx.fillStyle = '#007BFF';
ctx.fillRect(rectX, rectY, rectWidth, rectHeight);
ctx.fillText(patientName, rectX + 8, rectY + 20); // → "***********"
});

// Drop the geometry entirely when the SHAPE itself is sensitive:
maskCanvasDraws(ctx, () => drawJSTSGeometry(sensitiveGeometry, ctx), { shape: 'remove' });
}

What happens:

  • Each draw inside the callback is redacted, not silently dropped: shapes are recolored to the redaction color (shape: 'color', the default — same geometry, redacted fill/stroke), and fillText/strokeText are masked to length-preserving asterisks (text: 'asterisks', the default). State setters inside the block are overridden, not honored.
  • The masked pixel regions are blanked on every keyframe so the real pixels never ship; the canvas stays pixel-perfect outside the masked regions, and stays command-only (it never demotes to video).

Per-call style — override the global canvasRedactStyle for one block:

maskCanvasDraws(ctx, () => drawChartLabels(ctx), { text: 'bar' });        // solid bar over text
maskCanvasDraws(ctx, () => drawSecretRegion(ctx), { shape: 'block' }); // solid block over the bbox
maskCanvasDraws(ctx, () => drawSecretRegion(ctx), { shape: 'remove' }); // drop the geometry
maskCanvasDraws(ctx, () => drawLabeledShape(ctx), { keepShapes: true }); // redact only the text
  • text: 'asterisks' (default) | 'bar' (a solid redaction bar over the text box).
  • shape: 'color' (default — same shape recolored) | 'block' (solid block over the bounding box) | 'remove' (drop the geometry, e.g. when coordinates themselves are sensitive).
  • keepShapes: redact only text inside the block; leave shapes visible. Note: "shapes" includes images — a drawImage() inside a keepShapes block records and ships normally, so do not draw a sensitive image inside a keepShapes block (use the default block, shape: 'remove', or a whole-canvas mask for sensitive images).

Guidelines:

  • Keep the wrapped block self-contained — set the fillStyle / strokeStyle / transform you need inside it (or use ctx.save() / ctx.restore()). Masked state setters are overridden, so a style you change inside but rely on outside won't carry over in replay.
  • maskCanvasDraws is 2D-only. For WebGL / WebGPU (whose content is pixels, not commands), exclude the whole canvas with data-sf-mask / maskCanvasClass.
  • It is a safe no-op when recording is off or the canvas isn't being recorded — leave the wrappers in your render loop unconditionally.

Automatic PII redaction (content detection)

Independently of selectors, when the data-sensitivity settings record_credit_card_info, record_ssn, or record_dob are off (the default), Sailfish scans drawn text and redacts matching credit-card (Luhn-validated), SSN, and date-of-birth values in the stored command stream — even on un-masked canvases. This protects the verbatim string in the recording. Note it redacts the command text only: a keyframe or video frame of an un-masked canvas still renders the on-screen pixels, so for a canvas that can show sensitive data, use the canvas mask above for full protection. (PII split across two separate fillText() calls is not detected — mask such canvases.)

Example: a billing dashboard with sensitive and non-sensitive canvases

A dashboard renders three canvases — an account chart that draws the customer's name and account number, an invoice preview inside a PII section, and a decorative sparkline with no sensitive data. Exclude the first two, let the last record normally:

<!-- 1) Exclude with the data-sf-mask attribute — records NOTHING. -->
<canvas id="account-chart" data-sf-mask width="640" height="320"></canvas>

<!-- 2) Exclude by class — everything under .billing-pii is excluded too. -->
<section class="billing-pii">
<canvas id="invoice-preview" width="480" height="200"></canvas>
</section>

<!-- 3) No PII — records pixel-perfectly. -->
<canvas id="trend-sparkline" width="200" height="60"></canvas>
import { initRecorder } from '@sailfish-ai/recorder';

initRecorder({
apiKey: "<see-api-key-from-your-account-settings-page>",
serviceIdentifier: 'acme-corp/billing/src/index.tsx',
serviceVersion: '1.0.0',
recordCanvas: true, // turn canvas recording on
maskCanvasClass: 'billing-pii', // (optional) your own exclude class; defaults to "sailfishSanitize"
});

// #account-chart is masked → these draws are never recorded:
const acct = document.querySelector('#account-chart').getContext('2d');
acct.fillText('Jane Doe — Acct 4111 1111 1111 1111', 16, 24);

// #trend-sparkline is not masked → recorded verbatim (no PII here):
const trend = document.querySelector('#trend-sparkline').getContext('2d');
trend.fillText('Last 7 days', 4, 12);

In replay: #account-chart and #invoice-preview show a masked placeholder (no commands, keyframes, or video were ever sent); #trend-sparkline replays pixel-perfectly.

Keep the layout but hide the text. To let a canvas's shapes/axes replay pixel-perfectly while its text is redacted, use a text-mask selector (or the global maskCanvasText) instead of excluding it. Every fillText()/strokeText() becomes ****, keyframes are kept with only the text regions blanked, and the rest of the canvas stays pixel-perfect:

<canvas class="redact-text" width="480" height="240"></canvas>

…with maskTextSelector: '.redact-text' in your Capture Settings. (If your app already masks DOM text, canvas text is masked automatically by the mirror-DOM default — no extra selector needed unless you set maskCanvasText: false.)

Automatic safety net. Even on a canvas you forgot to mask, a stray card / SSN / DOB number is redacted from the stored commands automatically (the record_* flags are off by default):

ctx.fillText('Card 4111 1111 1111 1111', 8, 40);
// stored in the recording as: "Card **** **** **** 1111"

(Remember: this protects the stored command text, not pixels — for a canvas that visibly shows sensitive data, exclude it with data-sf-mask / maskCanvasClass / blockSelector.)

HIPAA

A HIPAA-enabled account still records canvas. The data is routed to the PHI processing pipeline downstream, exactly like other recorded events — HIPAA is a routing decision, not a capture switch. To keep a specific sensitive canvas out of recording entirely, mask it with data-sf-mask / maskCanvasClass / blockSelector as above.

Cross-origin canvases are never captured

If you draw a cross-origin (tainted) image or video onto a canvas without proper CORS headers, the browser marks the canvas as tainted and its pixels are unreadable. Sailfish detects this, captures no pixels for that canvas, and emits a placeholder marker instead — so a tainted canvas can never leak content.

Click-to-code on framework canvases

When you record a canvas drawn by a framework, a click in replay can resolve to the logical object you clicked — a three.js Mesh, a Fabric shape, a Chart.js datum — instead of raw pixels. Register your live framework instance so Sailfish can hit-test it. Each helper is imported from @sailfish-ai/recorder, binds to that framework's own canvas, returns an unregister function, and is a safe no-op when recording is off:

import {
registerThreeScene,
registerFabricCanvas,
registerKonvaStage,
registerPixiApp,
registerChart,
} from '@sailfish-ai/recorder';

// three.js — pass your scene, camera, renderer, AND your THREE namespace
// (the SDK never bundles three.js):
const stop = registerThreeScene(scene, camera, renderer, THREE);

// the others take the framework's primary object:
registerFabricCanvas(fabricCanvas); // a fabric.Canvas
registerKonvaStage(stage); // a Konva.Stage
registerPixiApp(app); // a PIXI.Application
registerChart(chart); // a Chart.js Chart

// each returns an unregister fn — call it on teardown (component unmount, etc.):
stop();

A click then surfaces the clicked object's identity (type + label, e.g. three Mesh: playerShip). To additionally resolve the click to the source line where you created that object (new THREE.Mesh(...)), enable creation-site stamping in the build plugin — see Build Plugin (createSiteStamping). Without the build plugin you still get the object identity; with it you also get the file:line.

Service Identification

OptionTypeDefaultDescription
serviceIdentifierstring""Unique identifier in <org>/<repo>/<path> format
serviceVersionstring""Version of your application
gitShastringAuto-detectedGit commit SHA for version tracking
serviceAdditionalMetadataRecord<string, any>{}Custom metadata to attach to sessions

Advanced Options

OptionTypeDefaultDescription
domainsToPropagateHeaderTostring[][]Allowlist: when non-empty, tracing headers are only sent to requests matching these domains (supports wildcards: *.example.com). Recommended when browser extensions cause conflicts.
domainsToNotPropagateHeaderTostring[]See belowBlocklist: domains to exclude from header propagation. When used with domainsToPropagateHeaderTo, acts as exceptions within the allowed set.
enableIpTrackingbooleanfalseFetch visitor IP in the background for session metadata
customBaseUrlstring--Custom base URL for the triage/report issue interface
reportIssueShortcutsPartial<ShortcutsConfig>{ enabled: false }Keyboard shortcuts for the issue reporting modal

Network Body Capture

OptionTypeDefaultDescription
captureStreamingResponseBodybooleantrueCapture a limited prefix of streaming response bodies
captureResponseBodyMaxMbnumber10Maximum response body size in MB to capture
captureStreamPrefixKbnumber64Maximum prefix size in KB to capture from streaming responses
captureStreamTimeoutMsnumber10000Timeout in milliseconds for streaming prefix capture

Performance Optimization

OptionTypeDefaultDescription
deferRecordingbooleantrueDefer heavy DOM recording until after the page is interactive
chunkSnapshotbooleanfalseBreak the initial full-DOM snapshot into async chunks

Advanced Configuration

Domain Filtering

Filter network request capture by domain:

import { initRecorder } from '@sailfish-ai/recorder';

initRecorder({
apiKey: "<see-api-key-from-your-account-settings-page>",
serviceIdentifier: 'acme-corp/web-app/src/index.tsx', // Format: <org>/<repo>/<path-to-this-file>
serviceVersion: '1.0.0',
// Exclude third-party services from header propagation
domainsToNotPropagateHeaderTo: ['analytics.google.com', 'sentry.io'],
});

Header Propagation

Control which domains receive tracing headers for distributed tracing across frontend and backend. The option supports the same wildcard syntax that the backend SDK uses — "*.example.com", "api.example.com:8080", "api.example.com/v1/*", etc.

Allowlist mode (recommended): When domainsToPropagateHeaderTo is set, headers are only injected into requests matching those domains. This prevents browser extensions (e.g., SimilarWeb, ad blockers) from intercepting requests with Sailfish headers, which can cause browser freezes or CORS errors.

import { initRecorder } from '@sailfish-ai/recorder';

initRecorder({
apiKey: "<see-api-key-from-your-account-settings-page>",
serviceIdentifier: 'acme-corp/web-app/src/index.tsx',
serviceVersion: '1.0.0',
// Only inject tracing headers for requests to your own backend
domainsToPropagateHeaderTo: ['api.myapp.com', '*.internal.com'],
});

Combined allowlist + blocklist: When both are provided, a request must match the allowlist and not match the blocklist. This lets you broadly allow a domain while excluding specific subdomains:

initRecorder({
apiKey: "<see-api-key-from-your-account-settings-page>",
serviceIdentifier: 'acme-corp/web-app/src/index.tsx',
serviceVersion: '1.0.0',
domainsToPropagateHeaderTo: ['*.mycompany.com'],
// Exclude auth subdomain that uses a third-party provider
domainsToNotPropagateHeaderTo: ['auth.mycompany.com'],
});

Completely disable propagation — pass an empty allow list:

initRecorder({
apiKey: "<see-api-key-from-your-account-settings-page>",
serviceIdentifier: 'acme-corp/web-app/src/index.tsx',
serviceVersion: '1.0.0',
domainsToPropagateHeaderTo: [], // kill switch — no URL receives the header
});
Auto-Installation populates this for you

If you onboarded via Auto-Installation PRs, domainsToPropagateHeaderTo is pre-populated with wildcard entries for every backend service we detected in your connected repositories (e.g. "*.acme.com", "api.acme.com/*"). Browser CORS rules mean the header is only sent where your server explicitly opts in via Access-Control-Allow-Headers, so the worst case of a too-permissive allowlist is the header being stripped by the browser — not sent to unintended origins.

info

By default (when domainsToPropagateHeaderTo is empty), tracing headers are sent to all domains except a built-in denylist of common third-party services (Twitter, Gravatar, Zendesk, AWS, etc.). Setting an allowlist is recommended for production to avoid conflicts with browser extensions.

Source File Tracking (Click-to-Code)

Mapping UI elements back to their source files — so that selecting a click in a session replay jumps to the exact file:line that rendered it — is handled by the frontend build plugin, @sailfish-ai/sf-map-utils, not by the recorder at runtime.

The build plugin stamps interactive elements at build time (zero runtime overhead) and uploads a source manifest alongside your source maps. See the Build Plugin guide for per-toolchain setup.

Upgrading

If you already use @sailfish-ai/sf-map-utils for source maps on Vite, Rollup, esbuild, or webpack, click-to-code turns on automatically when you bump the package version — there is no new configuration. Next.js and the tsc/SystemJS/UglifyJS family need a one-time registration; see the Build Plugin guide.

Framework Examples

Create React App

// src/index.tsx
import { initRecorder } from '@sailfish-ai/recorder';

initRecorder({
apiKey: "<see-api-key-from-your-account-settings-page>",
serviceIdentifier: 'acme-corp/react-app/src/index.tsx', // Format: <org>/<repo>/<path-to-this-file>
serviceVersion: '1.0.0',
});

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';

const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

Next.js (App Router)

// app/layout.tsx
'use client';
import { useEffect } from 'react';

export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
useEffect(() => {
import('@sailfish-ai/recorder').then(({ initRecorder }) => {
initRecorder({
apiKey: "<see-api-key-from-your-account-settings-page>",
serviceIdentifier: 'acme-corp/nextjs-app/app/layout.tsx', // Format: <org>/<repo>/<path-to-this-file>
serviceVersion: '1.0.0',
});
});
}, []);

return (
<html lang="en">
<body>{children}</body>
</html>
);
}
Server-side instrumentation

The code above captures browser-side telemetry (console logs, network requests, errors). To also capture server-side telemetry (API routes, server components, server actions), add an instrumentation.ts file to your project root. See the Backend JS/TS setup guide for the full instrumentation.ts pattern.

Next.js (Pages Router)

// pages/_app.tsx
import { useEffect } from 'react';
import type { AppProps } from 'next/app';

function MyApp({ Component, pageProps }: AppProps) {
useEffect(() => {
import('@sailfish-ai/recorder').then(({ initRecorder }) => {
initRecorder({
apiKey: "<see-api-key-from-your-account-settings-page>",
serviceIdentifier: 'acme-corp/nextjs-app/pages/_app.tsx', // Format: <org>/<repo>/<path-to-this-file>
serviceVersion: '1.0.0',
});
});
}, []);

return <Component {...pageProps} />;
}

export default MyApp;

Vite

// src/main.tsx
import { initRecorder } from '@sailfish-ai/recorder';

initRecorder({
apiKey: "<see-api-key-from-your-account-settings-page>",
serviceIdentifier: 'acme-corp/vite-app/src/main.tsx', // Format: <org>/<repo>/<path-to-this-file>
serviceVersion: '1.0.0',
});

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';

ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

Verifying the Setup

  1. Deploy your frontend with the recorder configured
  2. Open your app in the browser
  3. Open the Sailfish dashboard
  4. Trigger some activity -- you should see telemetry appearing

Troubleshooting

Logs not appearing

  1. Check the API key: Ensure apiKey is set to your Enterprise API key
  2. Check browser console: Look for errors related to the recorder
  3. Check network tab: Verify requests to api-service.sailfish.ai are succeeding

CORS errors

  1. The Sailfish cloud endpoint handles CORS automatically
  2. If you see CORS errors, check that you are not overriding backendApi

Network requests not captured

  1. If domainsToPropagateHeaderTo is set, check if the domain matches the allowlist
  2. Check if the domain is in domainsToNotPropagateHeaderTo or the built-in denylist
  3. Requests to the Sailfish endpoint itself are not captured (to prevent loops)

Combining with Backend

When using both frontend and backend instrumentation:

  1. Frontend logs will show with source type "browser"
  2. Backend logs will show with your serviceIdentifier
  3. Use the service filter in the Sailfish dashboard to separate them
  4. Network requests from frontend to backend will be visible

Next Steps


Local Development

Looking to set up SF Veritas for local development with the Desktop App? See the Desktop App frontend guide.