diff --git a/apps/docs/docs.json b/apps/docs/docs.json index e756f1c15e..822dd2fc84 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -221,7 +221,12 @@ }, { "group": "Resources", - "pages": ["guides/general/accessibility", "guides/general/security", "resources/license"] + "pages": [ + "guides/general/accessibility", + "resources/telemetry", + "guides/general/security", + "resources/license" + ] } ] }, diff --git a/apps/docs/resources/telemetry.mdx b/apps/docs/resources/telemetry.mdx new file mode 100644 index 0000000000..80ffdb7b45 --- /dev/null +++ b/apps/docs/resources/telemetry.mdx @@ -0,0 +1,244 @@ +--- +title: Telemetry +sidebarTitle: Telemetry +keywords: "telemetry, analytics, usage tracking, billing, license key, document tracking" +--- + +SuperDoc collects lightweight telemetry to track document opens for usage-based billing. Telemetry is enabled by default and runs silently in the background — it never blocks rendering or breaks your app. + +## What gets collected + +Each time a document is opened or imported, SuperDoc sends a single event containing: + +| Field | Description | Example | +|-------|-------------|---------| +| `documentId` | A hashed document identifier (not the content itself) | `a1b2c3d4...` | +| `documentCreatedAt` | The document's original creation timestamp | `2024-01-15T10:30:00Z` | +| `superdocVersion` | The version of the SuperDoc library | `1.15.0` | +| `browserInfo` | User agent, hostname, and screen size | `{ hostname: "app.example.com", ... }` | +| `metadata` | Custom key-value pairs you optionally provide | `{ customerId: "123" }` | + +SuperDoc does **not** collect: +- Document content or text +- User identities or personal data +- Keystrokes, edits, or change history +- Cookies or session tokens (`credentials: 'omit'`) + +## How it works + +1. You open or import a document +2. SuperDoc generates a **document identifier** — a hash derived from the file's metadata (not its content) +3. A single `POST` request fires to the telemetry endpoint +4. If the request fails (network error, blocked by firewall), it fails silently + +``` +Document opened → Generate identifier hash → POST to endpoint → Done +``` + + +The telemetry request is non-blocking and fire-and-forget. Your editor loads regardless of the outcome. + + +## Configuration + +Pass `licenseKey` and `telemetry` in your SuperDoc or SuperEditor config: + + + +```javascript SuperDoc +import { SuperDoc } from 'superdoc'; + +const superdoc = new SuperDoc({ + selector: '#editor', + document: yourFile, + licenseKey: 'your-license-key', + telemetry: { + enabled: true, + }, +}); +``` + +```javascript SuperEditor +import { Editor } from 'superdoc/super-editor'; + +const editor = await Editor.open(file, { + element: document.querySelector('#editor'), + licenseKey: 'your-license-key', + telemetry: { + enabled: true, + }, +}); +``` + + + +### Options + + + Your organization's license key. Links document opens to your account for billing. + Defaults to `community-and-eval-agplv3` if not provided. + + + + Enable or disable telemetry. Default: `true`. + + + + Override the telemetry endpoint. Default: `https://ingest.superdoc.dev/v1/collect`. Useful for proxying through your own infrastructure. + + + + Custom key-value pairs included with every event. Use this to attach your own identifiers (customer ID, environment, etc.). + + +### Disabling telemetry + +Set `enabled: false` to turn telemetry off entirely: + +```javascript +const superdoc = new SuperDoc({ + selector: '#editor', + document: yourFile, + telemetry: { + enabled: false, + }, +}); +``` + +No network requests will be made. Document metadata (GUID and timestamp) is still generated locally so files export correctly. + +## License key + +The license key identifies your organization. It's sent as an `X-License-Key` header with every telemetry request. + +| License type | Key | How to get it | +|-------------|-----|---------------| +| Community / evaluation | `community-and-eval-agplv3` (default) | No action needed — used automatically | +| Commercial | Your organization key | Email [q@superdoc.dev](mailto:q@superdoc.dev) | + +If you're evaluating SuperDoc or using it under AGPLv3, you don't need to configure a license key. The community key is applied automatically. + +For a commercial license key, email [q@superdoc.dev](mailto:q@superdoc.dev). You'll receive a key tied to your organization that unlocks usage tracking for your account. + +## How document uniqueness works + +SuperDoc needs to identify each document uniquely so that opening the same file twice counts as one document, not two. This matters for accurate billing. + +### Identifier strategies + +SuperDoc uses two strategies depending on what metadata the file contains: + +**1. Metadata hash — file already has a GUID and timestamp** + +DOCX files created by Microsoft Word (and other tools) typically contain a unique GUID and a creation timestamp in their internal metadata (`docProps/` and `word/settings.xml`). When both are present, SuperDoc hashes them together: + +``` +documentId = hash(GUID | creationTimestamp) → "HASH-A1B2C3D4" +``` + +This is the most stable strategy. The same file always produces the same identifier regardless of content edits, because the hash is derived from metadata, not content. + +**2. Generated identifier — file is missing metadata** + +Some tools produce DOCX files without a GUID or timestamp. When either is missing, SuperDoc does two things: + +1. **For the current open**: generates a content hash of the raw file bytes so the same file still produces a consistent identifier +2. **For future opens**: generates the missing GUID and/or timestamp and embeds them into the document's metadata + +The generated metadata is saved when you export. On the next import, the file now has both a GUID and timestamp, so SuperDoc switches to the stable metadata hash automatically. + +``` +First open: no GUID in file → content hash used, GUID + timestamp generated +Export: GUID + timestamp written into the file +Next open: GUID + timestamp found → metadata hash (stable from now on) +``` + +This self-healing behavior means document identification improves automatically as files pass through SuperDoc. After one export cycle, every document gets a permanent, edit-resistant identifier. + +## Payload example + +Here's what a telemetry request looks like on the wire: + +``` +POST https://ingest.superdoc.dev/v1/collect +Content-Type: application/json +X-License-Key: your-license-key +``` + +```json +{ + "superdocVersion": "1.15.0", + "browserInfo": { + "userAgent": "Mozilla/5.0...", + "currentUrl": "https://app.example.com/doc/123", + "hostname": "app.example.com", + "screenSize": { "width": 1920, "height": 1080 } + }, + "metadata": { + "customerId": "cust-456" + }, + "events": [ + { + "timestamp": "2026-02-12T14:30:00.000Z", + "documentId": "a1b2c3d4e5f6...", + "documentCreatedAt": "2024-01-15T10:30:00Z" + } + ] +} +``` + +## Try it: document counting demo + +See document uniqueness in action. Upload a DOCX file and watch the counter — then try the steps below. + +import { DocCounter } from '/snippets/components/doc-counter.jsx' + + + + + + Click **Upload DOCX** and pick any `.docx` file. The counter goes to **1**. SuperDoc reads the file's internal metadata and generates a document identifier. + + + Make some changes in the editor — add text, delete a paragraph, change formatting. The identifier stays the same because it's based on metadata, not content. + + + Click **Export & re-import**. SuperDoc exports the document to DOCX and immediately re-imports it. The counter stays at **1** — same metadata, same identifier. + + + Upload a second DOCX file. The counter goes to **2**. Each file with distinct metadata gets its own identifier. + + + Upload the original file again. The counter stays at **2**. SuperDoc recognizes it as the same document. + + + + +The event log at the bottom shows exactly what SuperDoc sees: the identifier hash, whether a document is new or recognized, and how many times each has been opened. + + +## FAQ + + + + +No. The request is non-blocking and fire-and-forget. Your editor loads and renders regardless of the telemetry outcome. + + + +Nothing. Errors are caught silently. No retries, no queuing, no user-visible errors. Your app continues to work normally. + + + +Yes. Set `telemetry.endpoint` to your proxy URL. The payload format stays the same. + + + +Not if it has metadata. SuperDoc hashes the document's GUID and creation timestamp, so the same file produces the same identifier every time. See [How document uniqueness works](#how-document-uniqueness-works) above. + + + +No. The community key (`community-and-eval-agplv3`) is applied automatically when you don't provide one. + + + diff --git a/apps/docs/snippets/components/doc-counter.jsx b/apps/docs/snippets/components/doc-counter.jsx new file mode 100644 index 0000000000..d2c7284edb --- /dev/null +++ b/apps/docs/snippets/components/doc-counter.jsx @@ -0,0 +1,263 @@ +export const DocCounter = ({ height = '350px' }) => { + const [documents, setDocuments] = useState(new Map()); + const [currentDoc, setCurrentDoc] = useState(null); + const [ready, setReady] = useState(false); + const [log, setLog] = useState([]); + const superdocRef = useRef(null); + const containerIdRef = useRef(`editor-${Math.random().toString(36).substr(2, 9)}`); + + const addLog = (message) => { + setLog((prev) => [...prev, { time: new Date().toLocaleTimeString(), message }]); + }; + + useEffect(() => { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = 'https://unpkg.com/superdoc@latest/dist/style.css'; + document.head.appendChild(link); + + const script = document.createElement('script'); + script.src = 'https://unpkg.com/superdoc@latest/dist/superdoc.umd.js'; + script.onload = () => setTimeout(() => initializeSuperdoc(), 100); + document.body.appendChild(script); + + return () => superdocRef.current?.destroy?.(); + }, []); + + const getEditor = () => { + return superdocRef.current?.activeEditor || superdocRef.current?.editor; + }; + + const trackDocument = async (name) => { + const editor = getEditor(); + if (!editor) { + setCurrentDoc({ name, identifier: null, hasGuid: false }); + return; + } + + let identifier = null; + let guid = null; + + try { + identifier = await editor.getDocumentIdentifier(); + guid = editor.getDocumentGuid(); + } catch (e) { + addLog(`Error: ${e?.message || e}`); + } + + if (identifier) { + setDocuments((prev) => { + const next = new Map(prev); + if (next.has(identifier)) { + const existing = next.get(identifier); + next.set(identifier, { ...existing, opens: existing.opens + 1 }); + addLog(`Re-opened "${name}" — same identifier, still counts as 1`); + } else { + next.set(identifier, { name, opens: 1, hasGuid: !!guid }); + addLog(`New document "${name}" — identifier: ${identifier.slice(0, 12)}...`); + } + return next; + }); + } + + setCurrentDoc({ name, identifier, hasGuid: !!guid }); + }; + + const initializeSuperdoc = (file = null) => { + if (superdocRef.current) { + superdocRef.current.destroy?.(); + } + + setReady(false); + + const config = { + selector: `#${containerIdRef.current}`, + telemetry: { enabled: false }, + onReady: async () => { + setReady(true); + if (file) { + await trackDocument(file.name); + } + }, + }; + + if (file) { + config.document = { data: file, type: 'docx' }; + } else { + config.html = '

Upload a DOCX file to see how document counting works.

'; + } + + if (window.SuperDocLibrary) { + superdocRef.current = new window.SuperDocLibrary.SuperDoc(config); + } + }; + + const handleFileUpload = async (e) => { + const file = e.target.files[0]; + if (!file?.name.endsWith('.docx')) return; + addLog(`Uploading "${file.name}"...`); + initializeSuperdoc(file); + e.target.value = ''; + }; + + const handleExportAndReimport = async () => { + if (!superdocRef.current?.export) return; + + addLog('Exporting document...'); + const blob = await superdocRef.current.export(); + + if (!blob) { + addLog('Export returned no data'); + return; + } + + const name = currentDoc?.name || 'document.docx'; + const file = new File([blob], name, { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }); + + addLog('Re-importing exported document...'); + initializeSuperdoc(file); + }; + + const uniqueCount = documents.size; + + return ( +
+ {/* Header with counter */} +
+
+
{uniqueCount}
+
+ Unique document{uniqueCount !== 1 ? 's' : ''} counted +
+
+
+ {ready && currentDoc && ( + + )} + +
+
+ + {/* Document list */} + {documents.size > 0 && ( +
+
+ Documents counting toward usage: +
+
+ {Array.from(documents.entries()).map(([id, doc]) => ( +
+ {doc.name} + {doc.opens > 1 && ( + (opened {doc.opens}x — still 1) + )} +
+ ))} +
+
+ )} + + {/* Editor */} +
+ + {/* Event log */} + {log.length > 0 && ( +
+ {log.map((entry, i) => ( +
+ {entry.time}{' '} + {entry.message} +
+ ))} +
+ )} + + +
+ ); +}; diff --git a/demos/__tests__/smoke.spec.ts b/demos/__tests__/smoke.spec.ts index 725e672972..fb3e003774 100644 --- a/demos/__tests__/smoke.spec.ts +++ b/demos/__tests__/smoke.spec.ts @@ -8,6 +8,9 @@ test('demo loads without errors', async ({ page }) => { if (msg.type() === 'error') errors.push(msg.text()); }); + // Block telemetry requests during tests + await page.route('**/ingest.superdoc.dev/**', (route) => route.abort()); + await page.goto('/'); await expect(page.locator('body')).toBeVisible(); diff --git a/devtools/visual-testing/packages/harness/src/App.vue b/devtools/visual-testing/packages/harness/src/App.vue index aff32419ca..6661b07d01 100644 --- a/devtools/visual-testing/packages/harness/src/App.vue +++ b/devtools/visual-testing/packages/harness/src/App.vue @@ -46,6 +46,11 @@ interface SuperDocConfig { commentsReadonly?: boolean; trackChanges?: boolean; }; + telemetry?: { + enabled: boolean; + endpoint?: string; + metadata?: Record; + }; document?: { data: File; type?: 'docx' | 'pdf'; @@ -148,6 +153,7 @@ function buildSuperdocConfig(): SuperDocConfig { selector: '#editor', pagination: config.layout, useLayoutEngine: config.layout, + telemetry: { enabled: false }, onReady, onTransaction, modules: {}, diff --git a/devtools/visual-testing/packages/test-helpers/src/navigation.ts b/devtools/visual-testing/packages/test-helpers/src/navigation.ts index 441ad4bbf0..cf7460f5dd 100644 --- a/devtools/visual-testing/packages/test-helpers/src/navigation.ts +++ b/devtools/visual-testing/packages/test-helpers/src/navigation.ts @@ -29,6 +29,9 @@ export interface GoToHarnessOptions extends Partial { export async function goToHarness(page: Page, options: GoToHarnessOptions = {}): Promise { const { baseUrl = DEFAULT_BASE_URL, timeout = 5_000, ...config } = options; + // Block telemetry requests during tests + await page.route('**/ingest.superdoc.dev/**', (route) => route.abort()); + const url = buildUrl(baseUrl, config); await page.goto(url); diff --git a/devtools/visual-testing/scripts/generate-refs.ts b/devtools/visual-testing/scripts/generate-refs.ts index 8c7ff6a1ac..d9bb495220 100644 --- a/devtools/visual-testing/scripts/generate-refs.ts +++ b/devtools/visual-testing/scripts/generate-refs.ts @@ -750,6 +750,8 @@ async function runForBrowser(browser: BrowserName, options: ParsedArgs): Promise deviceScaleFactor: scaleFactor, }); const page = await context.newPage(); + // Block telemetry requests during tests + await page.route('**/ingest.superdoc.dev/**', (route) => route.abort()); workers.push(processDocumentQueue(i + 1, page, queue, results, shouldSkipExisting, provider, progress, ci)); } diff --git a/e2e-tests/tests/helpers.js b/e2e-tests/tests/helpers.js index b39473728f..0f15a00156 100644 --- a/e2e-tests/tests/helpers.js +++ b/e2e-tests/tests/helpers.js @@ -26,6 +26,9 @@ export const goToPageAndWaitForEditor = async ( const url = params.toString() ? `http://localhost:4173/?${params.toString()}` : 'http://localhost:4173/'; + // Block telemetry requests during tests + await page.route('**/ingest.superdoc.dev/**', (route) => route.abort()); + await page.goto(url); await page.waitForSelector('div.super-editor'); const superEditor = page.locator('div.super-editor').first(); diff --git a/eslint.config.mjs b/eslint.config.mjs index d12c28d189..f1bbab2b40 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -56,6 +56,7 @@ export default [ clearInterval: 'readonly', // Browser APIs (client-side code) + crypto: 'readonly', window: 'readonly', document: 'readonly', navigator: 'readonly', @@ -68,6 +69,7 @@ export default [ requestAnimationFrame: 'readonly', URLSearchParams: 'readonly', TextDecoder: 'readonly', + TextEncoder: 'readonly', FileReader: 'readonly', DOMRect: 'readonly', diff --git a/examples/__tests__/smoke.spec.ts b/examples/__tests__/smoke.spec.ts index 754cd95ae3..6b38348313 100644 --- a/examples/__tests__/smoke.spec.ts +++ b/examples/__tests__/smoke.spec.ts @@ -8,6 +8,9 @@ test('example loads without errors', async ({ page }) => { if (msg.type() === 'error') errors.push(msg.text()); }); + // Block telemetry requests during tests + await page.route('**/ingest.superdoc.dev/**', (route) => route.abort()); + await page.goto('/'); await expect(page.locator('body')).toBeVisible(); diff --git a/examples/advanced/grading-papers-comments-annotations/src/App.jsx b/examples/advanced/grading-papers-comments-annotations/src/App.jsx index 4a75320afc..9542114e0b 100644 --- a/examples/advanced/grading-papers-comments-annotations/src/App.jsx +++ b/examples/advanced/grading-papers-comments-annotations/src/App.jsx @@ -77,7 +77,8 @@ const App = () => { selector: '#superdoc', document: { data: docFileRef.current }, toolbar: 'superdoc-toolbar', - licenseKey: 'community-and-eval-agplv3', + licenseKey: 'public_license_key_superdocinternal_ad7035140c4b', + telemetry: { enabled: false }, modules: { comments: {}, toolbar: { diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index dd6197a551..2175be551e 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -343,7 +343,7 @@ export class Editor extends EventEmitter { licenseKey: COMMUNITY_LICENSE_KEY, // Telemetry configuration - telemetry: null, + telemetry: { enabled: true }, }; /** @@ -483,7 +483,11 @@ export class Editor extends EventEmitter { #initTelemetry(): void { const { telemetry: telemetryConfig, licenseKey } = this.options; - // Skip if telemetry is not enabled + // Skip in test environments and when telemetry is not enabled + if (typeof process !== 'undefined' && (process.env?.VITEST || process.env?.NODE_ENV === 'test')) { + return; + } + if (!telemetryConfig?.enabled) { console.debug('[super-editor] Telemetry: disabled'); return; diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.js b/packages/super-editor/src/core/super-converter/SuperConverter.js index 57b4f6b4db..5207b487a7 100644 --- a/packages/super-editor/src/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/core/super-converter/SuperConverter.js @@ -1,6 +1,5 @@ import * as xmljs from 'xml-js'; import { v4 as uuidv4 } from 'uuid'; -import crc32 from 'buffer-crc32'; import { DocxExporter, exportSchemaToJson } from './exporter'; import { createDocumentJson, addDefaultStylesIfMissing } from './v2/importer/docxImporter.js'; import { deobfuscateFont, getArrayBufferFromUrl } from './helpers.js'; @@ -69,6 +68,33 @@ const collectRunDefaultProperties = ( } }; +/** + * SHA-256 hash helpers using the Web Crypto API. + * Works in all modern browsers and Node.js 20+. + */ +async function sha256Hex(bytes) { + const hash = await crypto.subtle.digest('SHA-256', bytes); + return Array.from(new Uint8Array(hash)) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + .toUpperCase(); +} + +async function hashString(str) { + return sha256Hex(new TextEncoder().encode(str)); +} + +async function hashFile(fileSource) { + if (fileSource instanceof ArrayBuffer) { + return sha256Hex(fileSource); + } else if (fileSource instanceof Blob || fileSource instanceof File) { + return sha256Hex(await fileSource.arrayBuffer()); + } else if (fileSource instanceof Uint8Array) { + return sha256Hex(fileSource); + } + return null; +} + class SuperConverter { static allowedElements = Object.freeze({ 'w:document': 'doc', @@ -752,44 +778,32 @@ class SuperConverter { /** * Generate identifier hash from documentGuid and dcterms:created - * Uses CRC32 of the combined string for a compact identifier + * Uses SHA-256 of the combined string for a compact identifier * Only call when both documentGuid and timestamp exist * @returns {string} Hash identifier in format "HASH-XXXXXXXX" */ - #generateIdentifierHash() { + async #generateIdentifierHash() { const combined = `${this.documentGuid}|${this.getDocumentCreatedTimestamp()}`; - const buffer = Buffer.from(combined, 'utf8'); - const hash = crc32(buffer); - return `HASH-${hash.toString('hex').toUpperCase()}`; + const hash = await hashString(combined); + return `HASH-${hash.substring(0, 8)}`; } /** * Generate content hash from file bytes - * Uses CRC32 of the raw file content for a stable identifier + * Uses SHA-256 of the raw file content for a stable identifier * @returns {Promise} Hash identifier in format "HASH-XXXXXXXX" */ async #generateContentHash() { if (!this.fileSource) { - // No file source available, generate a random hash (last resort) return `HASH-${uuidv4().replace(/-/g, '').substring(0, 8).toUpperCase()}`; } try { - let buffer; - - if (Buffer.isBuffer(this.fileSource)) { - buffer = this.fileSource; - } else if (this.fileSource instanceof ArrayBuffer) { - buffer = Buffer.from(this.fileSource); - } else if (this.fileSource instanceof Blob || this.fileSource instanceof File) { - const arrayBuffer = await this.fileSource.arrayBuffer(); - buffer = Buffer.from(arrayBuffer); - } else { + const hash = await hashFile(this.fileSource); + if (!hash) { return `HASH-${uuidv4().replace(/-/g, '').substring(0, 8).toUpperCase()}`; } - - const hash = crc32(buffer); - return `HASH-${hash.toString('hex').toUpperCase()}`; + return `HASH-${hash.substring(0, 8)}`; } catch (e) { console.warn('[super-converter] Could not generate content hash:', e); return `HASH-${uuidv4().replace(/-/g, '').substring(0, 8).toUpperCase()}`; @@ -821,7 +835,7 @@ class SuperConverter { if (hasGuid && hasTimestamp) { // Both exist: use identifierHash - this.documentUniqueIdentifier = this.#generateIdentifierHash(); + this.documentUniqueIdentifier = await this.#generateIdentifierHash(); } else { // Missing one or both: use contentHash for stability (same file = same hash) // But generate missing metadata so re-exported file will have complete metadata diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index 85569c39e6..1e1ac28d03 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -83,7 +83,7 @@ export class SuperDoc extends EventEmitter { licenseKey: COMMUNITY_LICENSE_KEY, // Telemetry settings - telemetry: { enabled: false }, // Enable to track document opens + telemetry: { enabled: true }, title: 'SuperDoc', conversations: [], diff --git a/packages/superdoc/src/dev/components/SuperdocDev.vue b/packages/superdoc/src/dev/components/SuperdocDev.vue index 4f67460055..a4943ae46b 100644 --- a/packages/superdoc/src/dev/components/SuperdocDev.vue +++ b/packages/superdoc/src/dev/components/SuperdocDev.vue @@ -178,9 +178,12 @@ const init = async () => { toolbarGroups: ['center'], role: userRole, documentMode: 'editing', - licenseKey: 'community-and-eval-agplv3', + licenseKey: 'public_license_key_superdocinternal_ad7035140c4b', telemetry: { - enabled: false, + enabled: true, + metadata: { + source: 'superdoc-dev' + } }, comments: { visible: true, diff --git a/tests/visual/harness/main.ts b/tests/visual/harness/main.ts index 4e0e4215d9..0b13c96d7a 100644 --- a/tests/visual/harness/main.ts +++ b/tests/visual/harness/main.ts @@ -26,6 +26,7 @@ function init(file?: File) { const config: any = { selector: '#editor', useLayoutEngine: layout, + telemetry: { enabled: false }, onReady: ({ superdoc }: any) => { (window as any).superdoc = superdoc; superdoc.activeEditor.on('create', ({ editor }: any) => {