Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion csr/csr-common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
],
"scripts": {
"prebuild": "node scripts/build-worker.mjs",
"build": "yarn run -T tsdown",
"build": "yarn run -T tsdown && node scripts/emit-worker-file.mjs",
"typecheck": "tsc --noEmit"
},
"publishConfig": {
Expand Down
16 changes: 9 additions & 7 deletions csr/csr-common/scripts/build-worker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ const { output } = await bundle.generate({
});

const code = output[0].code;
const out = resolve(pkgRoot, 'src/uploader/worker/worker-script.ts');
const body = `// Generated by scripts/build-worker.mjs at build time. Do not edit.
// Run \`yarn workspace @spotify-confidence/csr-common build:worker\` to regenerate.
export const workerScript: string = ${JSON.stringify(code)};
`;
writeFileSync(out, body);
console.log(`build-worker: wrote ${code.length} bytes to ${out}`);

const workerScriptOut = resolve(pkgRoot, 'src/uploader/worker/worker-script.ts');
writeFileSync(
workerScriptOut,
`// Generated by scripts/build-worker.mjs at build time. Do not edit.\n// Run \`yarn workspace @spotify-confidence/csr-common build:worker\` to regenerate.\nexport const workerScript: string = ${JSON.stringify(
code,
)};\n`,
);
console.log(`build-worker: wrote ${code.length} bytes to ${workerScriptOut}`);
13 changes: 13 additions & 0 deletions csr/csr-common/scripts/emit-worker-file.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/usr/bin/env node
import { writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const here = dirname(fileURLToPath(import.meta.url));
const pkgRoot = resolve(here, '..');

const { workerScript } = await import(resolve(pkgRoot, 'dist/uploader/index.js'));

const outPath = resolve(pkgRoot, 'dist/confidence-worker.js');
writeFileSync(outPath, workerScript);
console.log(`emit-worker-file: wrote ${workerScript.length} bytes to ${outPath}`);
109 changes: 109 additions & 0 deletions csr/session-recording/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ const recorder = initSessionRecorder({
// Recording mode
mode: 'automatic', // 'automatic' (default) or 'manual'

// CSP: self-hosted worker (only needed when data: and blob: are blocked)
// workerUrl: '/confidence-worker.js',

// Debug
debugLogger: msg => console.log(msg), // lifecycle/transport messages (default: off, or console.log when CSR_DEBUG is set in sessionStorage)
});
Expand Down Expand Up @@ -142,6 +145,112 @@ Then reload the page. The SDK will detect the flag and log to `console.log` auto

> **Tip:** We recommend enabling debug logging when first integrating the SDK. It lets you confirm that a session is established, events are flowing, and the backend is reachable — all before you open the Confidence dashboard.

## Content Security Policy (CSP)

The SDK runs its upload logic in a Web Worker. By default it loads the worker from a `data:` URL, which requires no setup but may be blocked by strict Content Security Policies.

### Required directives

| Directive | Value |
| ------------- | --------------------------------------------------------------------------- |
| `worker-src` | `data:` (default), or `blob:` (automatic fallback), or `'self'` (see below) |
| `connect-src` | `https://recording.confidence.dev wss://recording-ws.confidence.dev` |

### If `data:` is blocked

The SDK automatically falls back to a `blob:` URL. Most CSPs already allow `blob:` in `worker-src` — if yours does, no action is needed.

### If both `data:` and `blob:` are blocked

Self-host the worker script. The package ships a standalone file and a cross-platform command for copying it to your static assets.

**Vite**

Let Vite emit and version the worker as a build asset:

```typescript
import confidenceWorkerUrl from '@spotify-confidence/session-recording/confidence-worker.js?url';
import { initSessionRecorder } from '@spotify-confidence/session-recording';

const recorder = initSessionRecorder({
clientSecret: '<your-client-secret>',
workerUrl: confidenceWorkerUrl,
});
```

Vite's default asset inline limit keeps the worker as a separate file. If you increase `build.assetsInlineLimit`, make sure the worker is not emitted as a `data:` URL, since that would still require `data:` in `worker-src`.

**Next.js: copy to `public`**

Run the package-owned command before development and production builds:

```json
{
"scripts": {
"copy:confidence-worker": "confidence-copy-worker public/confidence-worker.js",
"predev": "npm run copy:confidence-worker",
"prebuild": "npm run copy:confidence-worker"
}
}
```

```typescript
const recorder = initSessionRecorder({
clientSecret: '<your-client-secret>',
workerUrl: '/confidence-worker.js',
});
```

The command creates parent directories and works without relying on a `node_modules` layout. You can also detect a stale or missing copy in CI:

```bash
confidence-copy-worker --check public/confidence-worker.js
```

If the app uses Next.js `basePath`, include it in `workerUrl` (for example, `/docs/confidence-worker.js`). A static filename should be served with revalidation rather than immutable caching so SDK upgrades can replace it.

**Next.js App Router: route handler**

As a no-copy alternative, serve the worker from a route handler:

```typescript
import { workerScript } from '@spotify-confidence/session-recording/worker';

export const dynamic = 'force-static';

export function GET() {
return new Response(workerScript, {
headers: {
'Content-Type': 'application/javascript; charset=utf-8',
'Cache-Control': 'public, max-age=0, must-revalidate',
},
});
}
```

Place this in `app/confidence-worker.js/route.ts` and use `/confidence-worker.js` as above. `force-static` also emits the route during a static export; the deployment host controls response headers for exported files.

**Other frameworks**

Copy the worker into your framework's static assets directory:

```bash
confidence-copy-worker path/to/static/confidence-worker.js
```

Then pass its same-origin public URL as `workerUrl`. Your CSP only needs `worker-src 'self'` with this setup.

> **Note:** The worker version must match the SDK version. After upgrading `@spotify-confidence/session-recording`, re-copy or re-deploy the worker file.

### Troubleshooting

If recording silently fails, open DevTools and look for:

- A `SecurityError` mentioning `worker-src` — your CSP blocks the worker. Use one of the options above.
- A blocked `connect-src` request to `recording.confidence.dev` — add the hosts to your CSP.

Enable [debug logging](#debug-logging) to see the full lifecycle and pinpoint where it fails.

## Manual mode

Use `manual` mode to control when recording starts — useful for gating on user consent or feature flags.
Expand Down
46 changes: 46 additions & 0 deletions csr/session-recording/bin/confidence-copy-worker.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/env node

import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const workerPath = fileURLToPath(new URL('../dist/confidence-worker.js', import.meta.url));
const args = process.argv.slice(2);
const check = args[0] === '--check';
const positionalArgs = check ? args.slice(1) : args;

if (positionalArgs.length !== 1 || positionalArgs[0].startsWith('-')) {
console.error('Usage: confidence-copy-worker [--check] <destination>');
process.exitCode = 1;
} else {
const destination = resolve(positionalArgs[0]);

try {
const worker = await readFile(workerPath);

if (check) {
let installedWorker;
try {
installedWorker = await readFile(destination);
} catch (error) {
if (error.code === 'ENOENT') {
throw new Error(`Worker is missing at ${destination}`);
}
throw error;
}

if (!worker.equals(installedWorker)) {
throw new Error(`Worker at ${destination} does not match the installed package`);
}

console.log(`Worker is up to date at ${destination}`);
} else {
await mkdir(dirname(destination), { recursive: true });
await writeFile(destination, worker);
console.log(`Copied Confidence worker to ${destination}`);
}
} catch (error) {
console.error(`confidence-copy-worker: ${error.message}`);
process.exitCode = 1;
}
}
14 changes: 12 additions & 2 deletions csr/session-recording/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,16 @@
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"bin": {
"confidence-copy-worker": "bin/confidence-copy-worker.mjs"
},
"scripts": {
"prebuild": "node sync-version.mjs",
"build": "yarn run -T tsdown",
"build": "yarn run -T tsdown && node scripts/emit-worker-file.mjs",
"typecheck": "tsc --noEmit"
},
"files": [
"bin",
"dist",
"src"
],
Expand All @@ -31,7 +35,13 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"./worker": {
"types": "./dist/worker.d.ts",
"import": "./dist/worker.js",
"require": "./dist/worker.cjs"
},
"./confidence-worker.js": "./dist/confidence-worker.js"
}
},
"dependencies": {
Expand Down
14 changes: 14 additions & 0 deletions csr/session-recording/scripts/emit-worker-file.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#!/usr/bin/env node
import { copyFileSync, mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const here = dirname(fileURLToPath(import.meta.url));
const pkgRoot = resolve(here, '..');

const src = resolve(pkgRoot, '..', 'csr-common', 'dist', 'confidence-worker.js');
const distDir = resolve(pkgRoot, 'dist');
mkdirSync(distDir, { recursive: true });
const dest = resolve(distDir, 'confidence-worker.js');
copyFileSync(src, dest);
console.log(`emit-worker-file: copied confidence-worker.js from csr-common`);
69 changes: 69 additions & 0 deletions csr/session-recording/src/copy-worker-cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const sourceCli = join(packageRoot, 'bin/confidence-copy-worker.mjs');

describe('confidence-copy-worker', () => {
let fixtureRoot: string;
let cli: string;
let worker: string;

beforeEach(() => {
fixtureRoot = mkdtempSync(join(tmpdir(), 'confidence-copy-worker-'));
cli = join(fixtureRoot, 'bin/confidence-copy-worker.mjs');
worker = join(fixtureRoot, 'dist/confidence-worker.js');
mkdirSync(dirname(cli), { recursive: true });
mkdirSync(dirname(worker), { recursive: true });
cpSync(sourceCli, cli);
writeFileSync(worker, 'self.__CONFIDENCE_WORKER__ = true;\n');
});

afterEach(() => {
rmSync(fixtureRoot, { recursive: true, force: true });
});

const run = (...args: string[]) =>
spawnSync(process.execPath, [cli, ...args], {
encoding: 'utf8',
});

it('copies the packaged worker and creates destination directories', () => {
const destination = join(fixtureRoot, 'public/assets/confidence-worker.js');

const result = run(destination);

expect(result.status).toBe(0);
expect(readFileSync(destination)).toEqual(readFileSync(worker));
});

it('passes --check when the destination is current', () => {
const destination = join(fixtureRoot, 'public/confidence-worker.js');
run(destination);

const result = run('--check', destination);

expect(result.status).toBe(0);
expect(result.stdout).toContain('Worker is up to date');
});

it.each([
['missing', undefined, 'Worker is missing'],
['stale', 'old worker contents', 'does not match the installed package'],
])('fails --check for a %s destination', (_condition, contents, expectedError) => {
const destination = join(fixtureRoot, 'public/confidence-worker.js');
if (contents) {
mkdirSync(dirname(destination), { recursive: true });
writeFileSync(destination, contents);
}

const result = run('--check', destination);

expect(result.status).toBe(1);
expect(result.stderr).toContain(expectedError);
});
});
15 changes: 15 additions & 0 deletions csr/session-recording/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,21 @@ describe('initSessionRecorder', () => {
expect(logger).toHaveBeenCalledWith(expect.stringContaining('worker-load-failed'));
});

it('forwards workerUrl to createUploader', async () => {
createUploader.mockResolvedValueOnce(mockUploader());
record.mockReturnValueOnce(() => {});

initSessionRecorder({
clientSecret: 'secret',
workerUrl: '/assets/confidence-worker.js',
});
await flushPromises();

expect(createUploader.mock.calls[0][0]).toMatchObject({
workerUrl: '/assets/confidence-worker.js',
});
});

it('manual mode does not init until start is called', async () => {
createUploader.mockResolvedValueOnce(mockUploader());
record.mockReturnValueOnce(() => {});
Expand Down
10 changes: 10 additions & 0 deletions csr/session-recording/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ export interface InitSessionRecorderOptions {
* `'manual'` — does nothing until `start()` is called, bypassing sampling and targeting rules.
*/
mode?: 'automatic' | 'manual';
/**
* URL of a self-hosted worker script. Required when your Content Security Policy
* blocks `data:` and `blob:` in `worker-src`. Serve the file exported from
* `@spotify-confidence/session-recording/worker` at a same-origin route and pass
* its URL here.
*
* The worker version must match the SDK version — a mismatch may cause silent failures.
*/
workerUrl?: string;
/** Verbose tracer for debugging — called with one-line lifecycle/transport messages. */
debugLogger?: (msg: string) => void;
}
Expand Down Expand Up @@ -129,6 +138,7 @@ export function initSessionRecorder(options: InitSessionRecorderOptions): Sessio
_csr_sdk_version: SDK_VERSION,
...(options.appVersion ? { _app_version: options.appVersion } : {}),
},
workerUrl: options.workerUrl,
forceRecord,
debugLogger,
onTerminate: ({ reason }) => {
Expand Down
1 change: 1 addition & 0 deletions csr/session-recording/src/worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { workerScript } from '@spotify-confidence/csr-common/uploader';
5 changes: 4 additions & 1 deletion csr/session-recording/tsdown.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { defineConfig } from 'tsdown';

export default defineConfig({
entry: './src/index.ts',
entry: {
index: './src/index.ts',
worker: './src/worker.ts',
},
format: ['esm', 'cjs'],
platform: 'browser',
minify: 'dce-only',
Expand Down
2 changes: 2 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5115,6 +5115,8 @@ __metadata:
dependencies:
"@spotify-confidence/csr-common": "workspace:*"
"@spotify-confidence/csr-recorder": "workspace:*"
bin:
confidence-copy-worker: bin/confidence-copy-worker.mjs
languageName: unknown
linkType: soft

Expand Down