From 64ce62e12fdd95516ff1ff19a63562ecffd8e09b Mon Sep 17 00:00:00 2001 From: aorlov Date: Wed, 4 Feb 2026 01:19:16 +0100 Subject: [PATCH 01/11] feat: telemetry - Added telemetry support to track document opens for usage-based billing. - Introduced `initTelemetry` method in the Editor class to initialize telemetry based on configuration. - Created tests for telemetry functionality, ensuring correct behavior when enabled/disabled and with various configurations. - Updated Editor and SuperDoc configurations to include telemetry options and license key handling. - Enhanced SuperConverter to manage document creation timestamps for unique identification. --- .../src/core/Editor.telemetry.test.ts | 221 +++++++ packages/super-editor/src/core/Editor.ts | 67 ++- .../core/super-converter/SuperConverter.js | 105 +++- .../src/core/types/EditorConfig.ts | 18 + packages/superdoc/src/SuperDoc.vue | 8 + packages/superdoc/src/core/SuperDoc.js | 6 + packages/superdoc/src/core/SuperDoc.test.js | 7 +- packages/superdoc/src/core/helpers/file.js | 1 - .../superdoc/src/core/helpers/file.test.js | 4 +- .../src/dev/components/SuperdocDev.vue | 27 +- packages/superdoc/src/telemetry.test.ts | 176 ++++++ shared/common/Telemetry.d.ts | 300 ++-------- shared/common/Telemetry.test.ts | 310 +++++++--- shared/common/Telemetry.ts | 538 +++--------------- shared/common/index.ts | 4 + 15 files changed, 982 insertions(+), 810 deletions(-) create mode 100644 packages/super-editor/src/core/Editor.telemetry.test.ts create mode 100644 packages/superdoc/src/telemetry.test.ts diff --git a/packages/super-editor/src/core/Editor.telemetry.test.ts b/packages/super-editor/src/core/Editor.telemetry.test.ts new file mode 100644 index 0000000000..dfe573b849 --- /dev/null +++ b/packages/super-editor/src/core/Editor.telemetry.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Telemetry } from '@superdoc/common'; + +// Mock the Telemetry class to verify it's called correctly +vi.mock('@superdoc/common', () => ({ + Telemetry: vi.fn().mockImplementation(() => ({ + trackDocumentOpen: vi.fn(), + isEnabled: vi.fn().mockReturnValue(true), + disable: vi.fn(), + enable: vi.fn(), + })), +})); + +// Test the telemetry initialization logic in isolation +// This mirrors the #initTelemetry method in Editor.ts +function initTelemetry(options: { + telemetry?: { enabled: boolean; endpoint?: string; metadata?: Record } | null; + licenseKey?: string | null; +}): Telemetry | null { + const { telemetry: telemetryConfig, licenseKey } = options; + + // Skip if telemetry is not enabled + if (!telemetryConfig?.enabled) { + return null; + } + + try { + return new Telemetry({ + config: { + enabled: true, + endpoint: telemetryConfig.endpoint, + licenseKey: licenseKey || undefined, + metadata: telemetryConfig.metadata, + }, + }); + } catch { + // Fail silently - telemetry should never break the app + return null; + } +} + +describe('Editor Telemetry Integration', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('telemetry disabled', () => { + it('does not create Telemetry instance when disabled', () => { + const result = initTelemetry({ + telemetry: { enabled: false }, + licenseKey: 'test-key', + }); + + expect(result).toBeNull(); + expect(Telemetry).not.toHaveBeenCalled(); + }); + + it('does not create Telemetry instance when telemetry config is null', () => { + const result = initTelemetry({ + telemetry: null, + licenseKey: 'test-key', + }); + + expect(result).toBeNull(); + expect(Telemetry).not.toHaveBeenCalled(); + }); + + it('does not create Telemetry instance when telemetry config is undefined', () => { + const result = initTelemetry({ + licenseKey: 'test-key', + }); + + expect(result).toBeNull(); + expect(Telemetry).not.toHaveBeenCalled(); + }); + }); + + describe('telemetry enabled', () => { + it('creates Telemetry instance when enabled', () => { + const result = initTelemetry({ + telemetry: { enabled: true }, + licenseKey: 'test-key', + }); + + expect(result).not.toBeNull(); + expect(Telemetry).toHaveBeenCalledTimes(1); + expect(Telemetry).toHaveBeenCalledWith({ + config: { + enabled: true, + endpoint: undefined, + licenseKey: 'test-key', + metadata: undefined, + }, + }); + }); + }); + + describe('telemetry without license key', () => { + it('creates Telemetry instance when enabled without license key', () => { + const result = initTelemetry({ + telemetry: { enabled: true }, + }); + + expect(result).not.toBeNull(); + expect(Telemetry).toHaveBeenCalledTimes(1); + expect(Telemetry).toHaveBeenCalledWith({ + config: { + enabled: true, + endpoint: undefined, + licenseKey: undefined, + metadata: undefined, + }, + }); + }); + + it('creates Telemetry instance when license key is null', () => { + const result = initTelemetry({ + telemetry: { enabled: true }, + licenseKey: null, + }); + + expect(result).not.toBeNull(); + expect(Telemetry).toHaveBeenCalledWith({ + config: { + enabled: true, + endpoint: undefined, + licenseKey: undefined, + metadata: undefined, + }, + }); + }); + }); + + describe('telemetry with custom endpoint', () => { + it('passes custom endpoint to Telemetry', () => { + const customEndpoint = 'https://custom.telemetry.com/v1/events'; + const result = initTelemetry({ + telemetry: { enabled: true, endpoint: customEndpoint }, + licenseKey: 'test-key', + }); + + expect(result).not.toBeNull(); + expect(Telemetry).toHaveBeenCalledWith({ + config: { + enabled: true, + endpoint: customEndpoint, + licenseKey: 'test-key', + metadata: undefined, + }, + }); + }); + }); + + describe('telemetry with metadata', () => { + it('passes metadata to Telemetry', () => { + const metadata = { + customerId: 'customer-123', + plan: 'enterprise', + }; + const result = initTelemetry({ + telemetry: { enabled: true, metadata }, + licenseKey: 'test-key', + }); + + expect(result).not.toBeNull(); + expect(Telemetry).toHaveBeenCalledWith({ + config: { + enabled: true, + endpoint: undefined, + licenseKey: 'test-key', + metadata, + }, + }); + }); + + it('passes nested metadata to Telemetry', () => { + const metadata = { + customerId: 'customer-123', + nested: { key: 'value', deep: { level: 2 } }, + }; + const result = initTelemetry({ + telemetry: { enabled: true, metadata }, + }); + + expect(result).not.toBeNull(); + expect(Telemetry).toHaveBeenCalledWith({ + config: { + enabled: true, + endpoint: undefined, + licenseKey: undefined, + metadata, + }, + }); + }); + }); + + describe('full configuration', () => { + it('passes all config options to Telemetry', () => { + const config = { + telemetry: { + enabled: true, + endpoint: 'https://custom.endpoint.com/collect', + metadata: { customerId: 'abc', env: 'production' }, + }, + licenseKey: 'license-key-123', + }; + + const result = initTelemetry(config); + + expect(result).not.toBeNull(); + expect(Telemetry).toHaveBeenCalledWith({ + config: { + enabled: true, + endpoint: 'https://custom.endpoint.com/collect', + licenseKey: 'license-key-123', + metadata: { customerId: 'abc', env: 'production' }, + }, + }); + }); + }); +}); diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index c51a136704..bd6f1eab6f 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -54,6 +54,7 @@ import type { EditorRenderer } from './renderers/EditorRenderer.js'; import { ProseMirrorRenderer } from './renderers/ProseMirrorRenderer.js'; import { BLANK_DOCX_DATA_URI } from './blank-docx.js'; import { getArrayBufferFromUrl } from '@core/super-converter/helpers.js'; +import { Telemetry } from '@superdoc/common'; declare const __APP_VERSION__: string; declare const version: string | undefined; @@ -240,6 +241,11 @@ export class Editor extends EventEmitter { */ setHighContrastMode?: (enabled: boolean) => void; + /** + * Telemetry instance for tracking document opens + */ + #telemetry: Telemetry | null = null; + options: EditorOptions = { element: null, selector: null, @@ -324,6 +330,12 @@ export class Editor extends EventEmitter { // header/footer editors may have parent(main) editor set parentEditor: null, + + // License key for billing + licenseKey: null, + + // Telemetry configuration + telemetry: null, }; /** @@ -390,6 +402,7 @@ export class Editor extends EventEmitter { this.#checkHeadless(resolvedOptions); this.setOptions(resolvedOptions); this.#renderer = resolvedOptions.renderer ?? (domAvailable ? new ProseMirrorRenderer() : null); + this.#initTelemetry(); const { setHighContrastMode } = useHighContrastMode(); this.setHighContrastMode = setHighContrastMode; @@ -447,12 +460,56 @@ export class Editor extends EventEmitter { } #emitCreateAsync(): void { + // Fire telemetry immediately - don't defer it since the editor might be + // destroyed before the next tick (e.g., in headless/CLI usage) + this.#trackDocumentOpen(); + setTimeout(() => { if (this.isDestroyed) return; this.emit('create', { editor: this }); }, 0); } + /** + * Initialize telemetry if configured + */ + #initTelemetry(): void { + const { telemetry: telemetryConfig, licenseKey } = this.options; + + // Skip if telemetry is not enabled + if (!telemetryConfig?.enabled) { + return; + } + + try { + this.#telemetry = new Telemetry({ + config: { + enabled: true, + endpoint: telemetryConfig.endpoint, + licenseKey, + metadata: telemetryConfig.metadata, + }, + }); + } catch { + // Fail silently - telemetry should never break the app + } + } + + /** + * Track document open event for telemetry + */ + #trackDocumentOpen(): void { + if (!this.#telemetry) return; + + try { + const documentId = this.getDocumentIdentifier(); + const documentCreatedAt = this.converter?.getDocumentCreatedTimestamp?.() || null; + this.#telemetry.trackDocumentOpen(documentId || null, documentCreatedAt); + } catch { + // Fail silently - telemetry should never break the app + } + } + /** * Assert that the editor is in one of the allowed states. * Throws InvalidStateError if not. @@ -988,6 +1045,7 @@ export class Editor extends EventEmitter { setTimeout(() => { if (this.isDestroyed) return; this.emit('create', { editor: this }); + this.#trackDocumentOpen(); }, 0); } @@ -1550,6 +1608,7 @@ export class Editor extends EventEmitter { documentId: this.options.documentId, mockWindow: this.options.mockWindow ?? null, mockDocument: this.options.mockDocument ?? null, + isNewFile: this.options.isNewFile ?? false, }); } } @@ -2115,10 +2174,12 @@ export class Editor extends EventEmitter { } /** - * Get document identifier (async - may generate hash) + * Get document identifier using hash(docId + dcterms:created) + * Returns a unique identifier that stays the same for the same document + * but changes on "Save As" or when creating from template. */ - async getDocumentIdentifier(): Promise { - return (await this.converter?.getDocumentIdentifier()) || null; + getDocumentIdentifier(): string | null { + return this.converter?.getDocumentIdentifier() || null; } /** diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.js b/packages/super-editor/src/core/super-converter/SuperConverter.js index b8f1b74912..93cfc536ca 100644 --- a/packages/super-editor/src/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/core/super-converter/SuperConverter.js @@ -245,6 +245,9 @@ class SuperConverter { this.documentHash = null; // Temporary hash for unmodified documents this.documentModified = false; // Track if document has been edited + // Track if this is a new file created from template + this.isNewFile = params?.isNewFile || false; + // Parse the initial XML, if provided if (this.docx.length || this.xml) this.parseFromXml(); } @@ -588,6 +591,48 @@ class SuperConverter { return SuperConverter.setStoredCustomProperty(docx, 'SuperdocVersion', version, false); } + /** + * Get the dcterms:created timestamp from the already-parsed core.xml + * @returns {string|null} The created timestamp in ISO format, or null if not found + */ + getDocumentCreatedTimestamp() { + const coreXml = this.convertedXml['docProps/core.xml']; + if (!coreXml) return null; + + const coreProps = coreXml.elements?.find( + (el) => el.name === 'cp:coreProperties' || SuperConverter._matchesElementName(el.name, 'coreProperties'), + ); + if (!coreProps?.elements) return null; + + const createdElement = coreProps.elements.find( + (el) => el.name === 'dcterms:created' || SuperConverter._matchesElementName(el.name, 'created'), + ); + + return createdElement?.elements?.[0]?.text || null; + } + + /** + * Set the dcterms:created timestamp in the already-parsed core.xml + * @param {string} timestamp - The timestamp to set (ISO format) + */ + setDocumentCreatedTimestamp(timestamp) { + const coreXml = this.convertedXml['docProps/core.xml']; + if (!coreXml) return; + + const coreProps = coreXml.elements?.find( + (el) => el.name === 'cp:coreProperties' || SuperConverter._matchesElementName(el.name, 'coreProperties'), + ); + if (!coreProps?.elements) return; + + const createdElement = coreProps.elements.find( + (el) => el.name === 'dcterms:created' || SuperConverter._matchesElementName(el.name, 'created'), + ); + + if (createdElement?.elements?.[0]) { + createdElement.elements[0].text = timestamp; + } + } + /** * Get document GUID from docx files (static method) * @static @@ -640,8 +685,15 @@ class SuperConverter { /** * Resolve existing document GUID (synchronous) + * For new files created from template, updates dcterms:created to make identifier unique */ resolveDocumentGuid() { + // If this is a new file created from template, update dcterms:created to current time + // This ensures documents created from the same template get unique identifiers + if (this.isNewFile) { + this.setDocumentCreatedTimestamp(new Date().toISOString()); + } + // 1. Check Microsoft's docId (READ ONLY) const microsoftGuid = this.getMicrosoftDocId(); if (microsoftGuid) { @@ -669,43 +721,36 @@ class SuperConverter { } /** - * Generate document hash (async, lazy) + * Generate document identifier hash from docId and created timestamp + * Uses CRC32 of the combined string for a compact identifier + * @returns {string} Hash identifier in format "HASH-XXXXXXXX" */ - async #generateDocumentHash() { - if (!this.fileSource) return `HASH-${Date.now()}`; + #generateIdentifierHash() { + const docId = this.documentGuid || uuidv4(); + const created = this.getDocumentCreatedTimestamp() || new Date().toISOString(); - 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 { - return `HASH-${Date.now()}`; - } + const combined = `${docId}|${created}`; + const buffer = Buffer.from(combined, 'utf8'); + const hash = crc32(buffer); - const hash = crc32(buffer); - return `HASH-${hash.toString('hex').toUpperCase()}`; - } catch (e) { - console.warn('Could not generate document hash:', e); - return `HASH-${Date.now()}`; - } + return `HASH-${hash.toString('hex').toUpperCase()}`; } /** - * Get document identifier (GUID or hash) - async for lazy hash generation + * Get document identifier using hash(docId + dcterms:created) + * + * This provides a unique identifier that: + * - Stays the same for the same document opened multiple times + * - Changes when a document is "Saved As" (dcterms:created changes) + * - Is unique for documents created from template (dcterms:created updated on creation) + * - Works with documents that don't have docId (uses generated UUID) + * + * @returns {string} Document identifier */ - async getDocumentIdentifier() { - if (this.documentGuid) { - return this.documentGuid; - } - - if (!this.documentHash && this.fileSource) { - this.documentHash = await this.#generateDocumentHash(); + getDocumentIdentifier() { + // Generate and cache the hash if not already done + if (!this.documentHash) { + this.documentHash = this.#generateIdentifierHash(); } return this.documentHash; diff --git a/packages/super-editor/src/core/types/EditorConfig.ts b/packages/super-editor/src/core/types/EditorConfig.ts index 2dd198243b..478b022514 100644 --- a/packages/super-editor/src/core/types/EditorConfig.ts +++ b/packages/super-editor/src/core/types/EditorConfig.ts @@ -415,4 +415,22 @@ export interface EditorOptions { * The static Editor.open() factory sets this automatically. */ deferDocumentLoad?: boolean; + + /** + * License key for billing and telemetry authentication. + */ + licenseKey?: string | null; + + /** + * Telemetry configuration for tracking document opens. + * When enabled, sends document open events for usage-based billing. + */ + telemetry?: { + /** Whether telemetry is enabled */ + enabled: boolean; + /** Custom telemetry endpoint (optional) */ + endpoint?: string; + /** Custom metadata to include with telemetry events (optional) */ + metadata?: Record; + } | null; } diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index 3c377a045a..fde584dd05 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -515,6 +515,14 @@ const editorOptions = (doc) => { isInternal: proxy.$superdoc.config.isInternal, ...payload, }), + licenseKey: proxy.$superdoc.config.licenseKey, + telemetry: proxy.$superdoc.config.telemetry?.enabled + ? { + enabled: true, + endpoint: proxy.$superdoc.config.telemetry?.endpoint, + metadata: proxy.$superdoc.config.telemetry?.metadata, + } + : null, }; return options; diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index 7e3f94d642..20de6c68f6 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -72,6 +72,12 @@ export class SuperDoc extends EventEmitter { modules: {}, // Optional: Modules to load. Use modules.ai.{your_key} to pass in your key permissionResolver: null, // Optional: Override for permission checks + // License key for organization identification + licenseKey: null, + + // Telemetry settings + telemetry: { enabled: false }, // Enable to track document opens + title: 'SuperDoc', conversations: [], isInternal: false, diff --git a/packages/superdoc/src/core/SuperDoc.test.js b/packages/superdoc/src/core/SuperDoc.test.js index fc591610d6..8024a3f6e1 100644 --- a/packages/superdoc/src/core/SuperDoc.test.js +++ b/packages/superdoc/src/core/SuperDoc.test.js @@ -713,8 +713,10 @@ describe('SuperDoc core', () => { id: expect.any(String), type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', name: 'contract.docx', - isNewFile: true, }); + // isNewFile should NOT be set when importing existing files + // It should only be true when creating from blank template + expect(instance.config.documents[0].isNewFile).toBeUndefined(); expect(instance.config.documents[0].data).toBe(file); }); @@ -737,8 +739,9 @@ describe('SuperDoc core', () => { id: expect.any(String), type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', name: 'document', // Default name for Blobs - isNewFile: true, }); + // isNewFile should NOT be set when importing existing files + expect(instance.config.documents[0].isNewFile).toBeUndefined(); // Blob should be wrapped as File expect(instance.config.documents[0].data).toBeInstanceOf(File); }); diff --git a/packages/superdoc/src/core/helpers/file.js b/packages/superdoc/src/core/helpers/file.js index 24a8679670..ec5bdcb48e 100644 --- a/packages/superdoc/src/core/helpers/file.js +++ b/packages/superdoc/src/core/helpers/file.js @@ -84,7 +84,6 @@ export const normalizeDocumentEntry = (entry) => { type, data: maybeFile, name, - isNewFile: true, }; } diff --git a/packages/superdoc/src/core/helpers/file.test.js b/packages/superdoc/src/core/helpers/file.test.js index 144aef1add..0b23bf96f6 100644 --- a/packages/superdoc/src/core/helpers/file.test.js +++ b/packages/superdoc/src/core/helpers/file.test.js @@ -55,8 +55,10 @@ describe('normalizeDocumentEntry', () => { expect(out).toMatchObject({ name: 'doc.docx', type: DOCX, - isNewFile: true, }); + // isNewFile is not set by normalizeDocumentEntry for direct files + // It should be set by the caller based on context + expect(out.isNewFile).toBeUndefined(); expect(out.data).toBeInstanceOf(File); expect(out.data).toBe(f); }); diff --git a/packages/superdoc/src/dev/components/SuperdocDev.vue b/packages/superdoc/src/dev/components/SuperdocDev.vue index d5265acc2c..7475019233 100644 --- a/packages/superdoc/src/dev/components/SuperdocDev.vue +++ b/packages/superdoc/src/dev/components/SuperdocDev.vue @@ -32,6 +32,13 @@ const activeEditor = shallowRef(null); const title = ref('initial title'); const currentFile = ref(null); +// Note: isNewFile is tracked here because the dev shell manually loads blank.docx on mount. +// In production, consumers don't need to track this - just omit the `document` config for new +// documents and SuperDoc will automatically create a blank document with isNewFile: true. +// This flag only matters when explicitly providing a document to distinguish between: +// - New document from template (isNewFile: true) → updates dcterms:created timestamp +// - Imported existing document (isNewFile: false) → preserves original timestamp +const isNewFile = ref(false); const commentsPanel = ref(null); const showCommentsPanel = ref(true); const sidebarInstanceKey = ref(0); @@ -95,8 +102,9 @@ const commentPermissionResolver = ({ permission, comment, defaultDecision, curre return defaultDecision; }; -const handleNewFile = async (file) => { +const handleNewFile = async (file, isNew = false) => { uploadedFileName.value = file?.name || ''; + isNewFile.value = isNew; // Generate a file url const url = URL.createObjectURL(file); @@ -107,8 +115,10 @@ const handleNewFile = async (file) => { if (isMarkdown || isHtml) { // For text-based files, read the content and use a blank DOCX as base + // These are considered "new" documents since they use the blank template const content = await readFileAsText(file); currentFile.value = await getFileObject(BlankDOCX, 'blank.docx', DOCX); + isNewFile.value = true; // Using blank template as base = new document // Store the content to be passed to SuperDoc if (isMarkdown) { @@ -119,6 +129,11 @@ const handleNewFile = async (file) => { } else { // For binary files (DOCX, PDF), use as-is currentFile.value = await getFileObject(url, file.name, file.type); + // Only set to false if not explicitly passed as new file + // (onMounted passes isNew=true for the initial blank.docx) + if (!isNew) { + isNewFile.value = false; // Imported file = not new + } } nextTick(() => { @@ -154,10 +169,12 @@ const init = async () => { const testDocumentId = 'doc123'; // Prepare document config with content if available + // isNewFile should only be true for blank/new documents, not imported files + // This ensures imported documents keep their original dcterms:created timestamp const documentConfig = { data: currentFile.value, id: testId, - isNewFile: true, + isNewFile: isNewFile.value, }; // Add markdown/HTML content if present @@ -175,6 +192,10 @@ const init = async () => { toolbarGroups: ['center'], role: userRole, documentMode: 'editing', + licenseKey: 'community-and-eval-agplv3', + telemetry: { + enabled: false + }, comments: { visible: true, }, @@ -526,7 +547,7 @@ onMounted(async () => { } const blankFile = await getFileObject(BlankDOCX, 'test.docx', DOCX); - handleNewFile(blankFile); + handleNewFile(blankFile, true); // true = new document, needs unique timestamp }); onBeforeUnmount(() => { diff --git a/packages/superdoc/src/telemetry.test.ts b/packages/superdoc/src/telemetry.test.ts new file mode 100644 index 0000000000..8830cb3064 --- /dev/null +++ b/packages/superdoc/src/telemetry.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect } from 'vitest'; + +// Test the telemetry config transformation logic used in SuperDoc.vue +// This mirrors the getEditorOptions function's telemetry handling +function transformTelemetryConfig(superdocConfig: { + telemetry?: { + enabled?: boolean; + endpoint?: string; + metadata?: Record; + } | null; + licenseKey?: string | null; +}): { + telemetry: { enabled: boolean; endpoint?: string; metadata?: Record } | null; + licenseKey?: string | null; +} { + return { + licenseKey: superdocConfig.licenseKey, + telemetry: superdocConfig.telemetry?.enabled + ? { + enabled: true, + endpoint: superdocConfig.telemetry?.endpoint, + metadata: superdocConfig.telemetry?.metadata, + } + : null, + }; +} + +describe('SuperDoc Telemetry Configuration', () => { + describe('telemetry disabled', () => { + it('returns null telemetry when not enabled', () => { + const result = transformTelemetryConfig({ + telemetry: { enabled: false }, + licenseKey: 'test-key', + }); + + expect(result.telemetry).toBeNull(); + expect(result.licenseKey).toBe('test-key'); + }); + + it('returns null telemetry when telemetry config is undefined', () => { + const result = transformTelemetryConfig({ + licenseKey: 'test-key', + }); + + expect(result.telemetry).toBeNull(); + }); + + it('returns null telemetry when telemetry config is null', () => { + const result = transformTelemetryConfig({ + telemetry: null, + licenseKey: 'test-key', + }); + + expect(result.telemetry).toBeNull(); + }); + }); + + describe('telemetry enabled', () => { + it('returns enabled telemetry config', () => { + const result = transformTelemetryConfig({ + telemetry: { enabled: true }, + licenseKey: 'test-key', + }); + + expect(result.telemetry).toEqual({ + enabled: true, + endpoint: undefined, + metadata: undefined, + }); + expect(result.licenseKey).toBe('test-key'); + }); + }); + + describe('telemetry without license key', () => { + it('sends telemetry without license key', () => { + const result = transformTelemetryConfig({ + telemetry: { enabled: true }, + }); + + expect(result.telemetry).toEqual({ + enabled: true, + endpoint: undefined, + metadata: undefined, + }); + expect(result.licenseKey).toBeUndefined(); + }); + + it('handles null license key', () => { + const result = transformTelemetryConfig({ + telemetry: { enabled: true }, + licenseKey: null, + }); + + expect(result.telemetry?.enabled).toBe(true); + expect(result.licenseKey).toBeNull(); + }); + }); + + describe('telemetry with custom endpoint', () => { + it('passes custom endpoint', () => { + const customEndpoint = 'https://custom.telemetry.com/v1/events'; + const result = transformTelemetryConfig({ + telemetry: { enabled: true, endpoint: customEndpoint }, + }); + + expect(result.telemetry).toEqual({ + enabled: true, + endpoint: customEndpoint, + metadata: undefined, + }); + }); + }); + + describe('telemetry with metadata', () => { + it('passes metadata to editor config', () => { + const metadata = { + customerId: 'customer-123', + plan: 'enterprise', + }; + const result = transformTelemetryConfig({ + telemetry: { enabled: true, metadata }, + }); + + expect(result.telemetry).toEqual({ + enabled: true, + endpoint: undefined, + metadata, + }); + }); + + it('passes complex nested metadata', () => { + const metadata = { + customerId: 'customer-123', + settings: { + theme: 'dark', + features: ['a', 'b', 'c'], + }, + }; + const result = transformTelemetryConfig({ + telemetry: { enabled: true, metadata }, + }); + + expect(result.telemetry?.metadata).toEqual(metadata); + }); + }); + + describe('full configuration flow', () => { + it('transforms complete SuperDoc config to Editor config', () => { + const superdocConfig = { + telemetry: { + enabled: true, + endpoint: 'https://api.example.com/telemetry', + metadata: { + customerId: 'cust-456', + environment: 'production', + }, + }, + licenseKey: 'lic-789', + }; + + const result = transformTelemetryConfig(superdocConfig); + + expect(result).toEqual({ + licenseKey: 'lic-789', + telemetry: { + enabled: true, + endpoint: 'https://api.example.com/telemetry', + metadata: { + customerId: 'cust-456', + environment: 'production', + }, + }, + }); + }); + }); +}); diff --git a/shared/common/Telemetry.d.ts b/shared/common/Telemetry.d.ts index 9374d830d3..419cafb61e 100644 --- a/shared/common/Telemetry.d.ts +++ b/shared/common/Telemetry.d.ts @@ -1,275 +1,67 @@ -type TelemetryAttributes = Record; -type ReadonlyTelemetryRecord = Readonly>; /** - * Standard telemetry event names + * SuperDoc Telemetry - Document Open Tracking + * + * Tracks document opens for usage-based billing. + * Sends immediately on each document open/import. + * Fails silently - never breaks the app. */ -export declare const TelemetryEventNames: { - readonly DOCUMENT_OPENED: 'document:opened'; - readonly DOCUMENT_PARSED: 'document:parsed'; - readonly DOCUMENT_EXPORTED: 'document:exported'; - readonly ERROR_OCCURRED: 'error:occurred'; - readonly FEATURE_USED: 'feature:used'; - readonly CONVERSION_STARTED: 'conversion:started'; - readonly CONVERSION_COMPLETED: 'conversion:completed'; -}; -/** - * Known telemetry event names (provides autocomplete) - */ -export type KnownTelemetryEvent = (typeof TelemetryEventNames)[keyof typeof TelemetryEventNames]; -/** - * Custom telemetry event name - use for application-specific events - * Branded type to distinguish from known events - */ -export type CustomTelemetryEvent = string & { - readonly __custom?: never; -}; -/** - * Telemetry event name - either a known event or a custom event - */ -export type TelemetryEventName = KnownTelemetryEvent | CustomTelemetryEvent; -/** - * Valid statistic categories for tracking - */ -export type StatisticCategory = 'node' | 'unknown' | 'error'; -/** - * Create a custom telemetry event name - * Use this for application-specific events not covered by TelemetryEventNames - * @param eventName - Custom event name (e.g., 'feature:custom-action') - * @returns Branded custom event name - */ -export declare function customTelemetryEvent(eventName: string): CustomTelemetryEvent; + export interface TelemetryConfig { - readonly licenseKey?: string; - readonly enabled?: boolean; - readonly endpoint?: string; - readonly documentGuid?: string; - readonly documentIdentifier?: string; - readonly superdocVersion: string; + enabled: boolean; + endpoint?: string; + licenseKey?: string; + metadata?: Record; } + export interface BrowserInfo { - readonly userAgent: string; - readonly currentUrl: string; - readonly hostname: string; - readonly referrerUrl: string; - readonly screenSize: { - readonly width: number; - readonly height: number; + userAgent: string; + currentUrl: string; + hostname: string; + screenSize: { + width: number; + height: number; }; } -export interface Statistics { - nodeTypes: Record; - markTypes: Record; - attributes: Record; - errorCount: number; -} -export interface FileInfo { - readonly path: string; - readonly name: string; - readonly size?: number; - readonly depth: number; -} -export interface FileStructure { - totalFiles: number; - maxDepth: number; - totalNodes: number; - files: FileInfo[]; -} -export interface DocumentInfo { - readonly guid?: string; - readonly identifier?: string; - readonly name: string; - readonly size: number; - readonly lastModified: string | null; - readonly type: string; - readonly internalId?: string; -} -export interface BaseTelemetryEvent { - id: string; + +export interface DocumentOpenEvent { timestamp: string; - sessionId: string; - documentGuid?: string; - documentIdentifier?: string; - superdocVersion: string; - file: DocumentInfo | null; - browser: BrowserInfo; -} -export interface TelemetryUsageEvent extends BaseTelemetryEvent { - type: 'usage'; - name: string; - properties: TelemetryAttributes; + documentId: string | null; + documentCreatedAt: string | null; } -export interface TelemetryParsingReport extends BaseTelemetryEvent { - type: 'parsing'; - statistics: Statistics; - fileStructure: FileStructure; - unknownElements: UnknownElement[]; - errors: TelemetryError[]; -} -export type TelemetryPayload = TelemetryUsageEvent | TelemetryParsingReport[]; -export interface TelemetryError extends ReadonlyTelemetryRecord { - readonly message?: string; - readonly elementName?: string; - readonly attributes?: TelemetryAttributes; - readonly timestamp?: string; + +export interface TelemetryPayload { + superdocVersion: string; + browserInfo: BrowserInfo; + metadata?: Record; + events: DocumentOpenEvent[]; } -export interface UnknownElement { - readonly elementName: string; - count: number; - attributes?: TelemetryAttributes; + +interface TelemetryOptions { + config: TelemetryConfig; } -/** - * Discriminated union for statistic data based on category - */ -export type StatisticData = - | { - category: 'node'; - elementName: string; - attributes?: TelemetryAttributes; - marks?: Array<{ - type: string; - }>; - } - | { - category: 'unknown'; - elementName: string; - attributes?: TelemetryAttributes; - } - | { - category: 'error'; - message?: string; - elementName?: string; - attributes?: TelemetryAttributes; - timestamp?: string; - [key: string]: unknown; - }; + export declare class Telemetry { - enabled: boolean; - documentGuid?: string; - documentIdentifier?: string; - superdocVersion: string; - licenseKey: string; - endpoint: string; - sessionId: string; - statistics: Statistics; - unknownElements: UnknownElement[]; - errors: TelemetryError[]; - fileStructure: FileStructure; - documentInfo: DocumentInfo | null; - static readonly COMMUNITY_LICENSE_KEY: 'community-and-eval-agplv3'; - static readonly DEFAULT_ENDPOINT: 'https://ingest.superdoc.dev/v1/collect'; - /** - * Initialize telemetry service - * @param config - Telemetry configuration - */ - constructor(config: TelemetryConfig); - /** - * Get browser environment information - * @returns Browser information - */ - getBrowserInfo(): BrowserInfo; - /** - * Track document usage event - * @param name - Event name (use TelemetryEventNames for standard events) - * @param properties - Additional properties - */ - trackUsage(name: TelemetryEventName, properties?: TelemetryAttributes): Promise; - /** - * Track parsing statistics (new discriminated union API) - * @param data - Statistic data with category discriminator - */ - trackStatistic(data: StatisticData): void; - /** - * Track parsing statistics (legacy API for backward compatibility) - * @param category - Statistic category - * @param data - Statistic data without category - * @deprecated Use the single-parameter overload with discriminated union instead - */ - trackStatistic( - category: 'node', - data: Omit< - Extract< - StatisticData, - { - category: 'node'; - } - >, - 'category' - >, - ): void; - trackStatistic( - category: 'unknown', - data: Omit< - Extract< - StatisticData, - { - category: 'unknown'; - } - >, - 'category' - >, - ): void; - trackStatistic( - category: 'error', - data: Omit< - Extract< - StatisticData, - { - category: 'error'; - } - >, - 'category' - >, - ): void; - /** - * Track file structure - * @param structure - File structure information - * @param fileSource - original file - * @param documentId - document GUID - * @param documentIdentifier - document identifier (GUID or hash) - * @param internalId - document ID from settings.xml - */ - trackFileStructure( - structure: FileStructure, - fileSource: File, - documentId?: string, - documentIdentifier?: string, - internalId?: string, - ): Promise; - /** - * Process document metadata - * @param file - Document file - * @param options - Additional options - * @returns Document metadata - */ - processDocument( - file: File | null, - options?: { - guid?: string; - identifier?: string; - internalId?: string; - }, - ): Promise; - isTelemetryDataChanged(): boolean; + constructor(options: TelemetryOptions); + /** - * Sends current report - * @returns Promise that resolves when report is sent + * Track a document open event - sends immediately + * @param documentId - Unique document identifier (GUID or hash), or null if unavailable + * @param documentCreatedAt - Document creation timestamp (dcterms:created), or null if unavailable */ - sendReport(): Promise; + trackDocumentOpen(documentId: string | null, documentCreatedAt?: string | null): void; + /** - * Sends data to the service - * @param data - Payload to send - * @returns Promise that resolves when data is sent + * Disable telemetry */ - sendDataToTelemetry(data: TelemetryPayload): Promise; + disable(): void; + /** - * Generate unique identifier - * @returns Unique ID - * @private + * Enable telemetry */ - generateId(): string; + enable(): void; + /** - * Reset statistics + * Check if telemetry is enabled */ - resetStatistics(): void; + isEnabled(): boolean; } -export {}; diff --git a/shared/common/Telemetry.test.ts b/shared/common/Telemetry.test.ts index 82c3e24c01..9093a2e62c 100644 --- a/shared/common/Telemetry.test.ts +++ b/shared/common/Telemetry.test.ts @@ -1,96 +1,276 @@ -import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from 'vitest'; -import { Telemetry } from './Telemetry'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Telemetry, TelemetryConfig, TelemetryPayload } from './Telemetry'; -const baseConfig = { - superdocVersion: 'test-version', -}; +describe('Telemetry', () => { + let fetchSpy: ReturnType; + const testConfig: TelemetryConfig = { + enabled: true, + endpoint: 'https://test.example.com/collect', + licenseKey: 'test-license-key', + }; -describe('Telemetry randomBytes', () => { - let originalCryptoDescriptor: PropertyDescriptor | undefined; - let originalMsCrypto: Crypto | undefined; + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response()); + }); - beforeAll(() => { - originalCryptoDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'crypto'); + afterEach(() => { + fetchSpy.mockRestore(); }); - beforeEach(() => { - originalMsCrypto = (globalThis as typeof globalThis & { msCrypto?: Crypto }).msCrypto; + describe('telemetry disabled', () => { + it('does not send telemetry when disabled', async () => { + const telemetry = new Telemetry({ + config: { ...testConfig, enabled: false }, + }); + telemetry.trackDocumentOpen('doc-123'); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('does not send telemetry after being disabled at runtime', async () => { + const telemetry = new Telemetry({ config: testConfig }); + telemetry.disable(); + telemetry.trackDocumentOpen('doc-123'); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(fetchSpy).not.toHaveBeenCalled(); + }); }); - afterEach(() => { - if (originalCryptoDescriptor) { - Object.defineProperty(globalThis, 'crypto', originalCryptoDescriptor); - } else { - // Ensure we don't leave a stubbed crypto behind - delete (globalThis as typeof globalThis & { crypto?: Crypto }).crypto; - } - (globalThis as typeof globalThis & { msCrypto?: Crypto }).msCrypto = originalMsCrypto; + describe('telemetry enabled', () => { + it('sends telemetry when enabled', async () => { + const telemetry = new Telemetry({ config: testConfig }); + telemetry.trackDocumentOpen('doc-123', '2024-01-15T10:30:00Z'); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledWith( + testConfig.endpoint, + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-License-Key': testConfig.licenseKey, + }, + }), + ); + + const callArgs = fetchSpy.mock.calls[0]; + const payload: TelemetryPayload = JSON.parse(callArgs[1]?.body as string); + + expect(payload.superdocVersion).toBeDefined(); + expect(payload.browserInfo).toBeDefined(); + expect(payload.events).toHaveLength(1); + expect(payload.events[0].documentId).toBe('doc-123'); + expect(payload.events[0].documentCreatedAt).toBe('2024-01-15T10:30:00Z'); + expect(payload.events[0].timestamp).toBeDefined(); + }); + + it('sends telemetry after being enabled at runtime', async () => { + const telemetry = new Telemetry({ + config: { ...testConfig, enabled: false }, + }); + telemetry.enable(); + telemetry.trackDocumentOpen('doc-123'); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('telemetry without license key', () => { + it('sends telemetry when enabled without license key', async () => { + const telemetry = new Telemetry({ + config: { enabled: true, endpoint: 'https://test.example.com/collect' }, + }); + telemetry.trackDocumentOpen('doc-123'); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://test.example.com/collect', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-License-Key': '', + }, + }), + ); + }); + }); + + describe('telemetry without document id', () => { + it('sends telemetry with null documentId', async () => { + const telemetry = new Telemetry({ config: testConfig }); + telemetry.trackDocumentOpen(null, null); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const callArgs = fetchSpy.mock.calls[0]; + const payload: TelemetryPayload = JSON.parse(callArgs[1]?.body as string); + expect(payload.events[0].documentId).toBeNull(); + expect(payload.events[0].documentCreatedAt).toBeNull(); + }); + + it('sends telemetry with documentId but no documentCreatedAt', async () => { + const telemetry = new Telemetry({ config: testConfig }); + telemetry.trackDocumentOpen('doc-456'); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const callArgs = fetchSpy.mock.calls[0]; + const payload: TelemetryPayload = JSON.parse(callArgs[1]?.body as string); + expect(payload.events[0].documentId).toBe('doc-456'); + expect(payload.events[0].documentCreatedAt).toBeNull(); + }); }); - it('uses crypto.getRandomValues when available', () => { - const getRandomValues = vi.fn((array: Uint8Array) => { - array.set([0xaa, 0xbb, 0xcc, 0xdd]); - return array; + describe('telemetry with custom endpoint', () => { + it('sends telemetry to custom endpoint when provided', async () => { + const customEndpoint = 'https://custom.telemetry.com/v1/events'; + const telemetry = new Telemetry({ + config: { enabled: true, endpoint: customEndpoint }, + }); + telemetry.trackDocumentOpen('doc-123'); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledWith(customEndpoint, expect.objectContaining({ method: 'POST' })); }); - if (!globalThis.crypto) { - Object.defineProperty(globalThis, 'crypto', { - configurable: true, - enumerable: true, - value: { getRandomValues } as Crypto, + it('sends telemetry to default endpoint when not provided', async () => { + const telemetry = new Telemetry({ + config: { enabled: true }, }); - } else { - vi.spyOn(globalThis.crypto, 'getRandomValues').mockImplementation(getRandomValues); - } + telemetry.trackDocumentOpen('doc-123'); - const telemetry = new Telemetry(baseConfig); - const id = telemetry.generateId(); + await new Promise((resolve) => setTimeout(resolve, 0)); - expect(getRandomValues).toHaveBeenCalled(); - expect(id.split('-')[1]).toBe('aabbccdd'); + expect(fetchSpy).toHaveBeenCalledTimes(1); + // Default endpoint is http://localhost:3051/v1/collect + expect(fetchSpy).toHaveBeenCalledWith( + 'http://localhost:3051/v1/collect', + expect.objectContaining({ method: 'POST' }), + ); + }); }); - it('falls back to Math.random when crypto is unavailable', () => { - Object.defineProperty(globalThis, 'crypto', { - configurable: true, - enumerable: true, - value: undefined, + describe('telemetry with metadata', () => { + it('includes metadata at root level when provided', async () => { + const configWithMetadata: TelemetryConfig = { + ...testConfig, + metadata: { + customerId: 'customer-123', + plan: 'enterprise', + nested: { key: 'value' }, + }, + }; + const telemetry = new Telemetry({ config: configWithMetadata }); + telemetry.trackDocumentOpen('doc-123'); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const callArgs = fetchSpy.mock.calls[0]; + const payload: TelemetryPayload = JSON.parse(callArgs[1]?.body as string); + + expect(payload.metadata).toBeDefined(); + expect(payload.metadata?.customerId).toBe('customer-123'); + expect(payload.metadata?.plan).toBe('enterprise'); + expect(payload.metadata?.nested).toEqual({ key: 'value' }); }); - (globalThis as typeof globalThis & { msCrypto?: Crypto }).msCrypto = undefined; - const mathRandomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.5); + it('omits metadata from payload when not provided', async () => { + const telemetry = new Telemetry({ config: testConfig }); + telemetry.trackDocumentOpen('doc-123'); - const telemetry = new Telemetry(baseConfig); - const id = telemetry.generateId(); + await new Promise((resolve) => setTimeout(resolve, 0)); - expect(mathRandomSpy).toHaveBeenCalled(); - expect(id.split('-')[1]).toHaveLength(8); + const callArgs = fetchSpy.mock.calls[0]; + const payload: TelemetryPayload = JSON.parse(callArgs[1]?.body as string); - mathRandomSpy.mockRestore(); + expect(payload.metadata).toBeUndefined(); + }); + + it('includes empty metadata object when provided as empty', async () => { + const telemetry = new Telemetry({ + config: { ...testConfig, metadata: {} }, + }); + telemetry.trackDocumentOpen('doc-123'); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const callArgs = fetchSpy.mock.calls[0]; + const payload: TelemetryPayload = JSON.parse(callArgs[1]?.body as string); + + expect(payload.metadata).toEqual({}); + }); }); - it('uses msCrypto.getRandomValues as fallback for older browsers', () => { - // Remove standard crypto - Object.defineProperty(globalThis, 'crypto', { - configurable: true, - enumerable: true, - value: undefined, + describe('payload structure', () => { + it('includes browser info at root level', async () => { + const telemetry = new Telemetry({ config: testConfig }); + telemetry.trackDocumentOpen('doc-123'); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const callArgs = fetchSpy.mock.calls[0]; + const payload: TelemetryPayload = JSON.parse(callArgs[1]?.body as string); + + expect(payload.browserInfo).toBeDefined(); + expect(payload.browserInfo.userAgent).toBeDefined(); + expect(payload.browserInfo.screenSize).toBeDefined(); + }); + + it('includes superdocVersion at root level', async () => { + const telemetry = new Telemetry({ config: testConfig }); + telemetry.trackDocumentOpen('doc-123'); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const callArgs = fetchSpy.mock.calls[0]; + const payload: TelemetryPayload = JSON.parse(callArgs[1]?.body as string); + + expect(payload.superdocVersion).toBeDefined(); + }); + }); + + describe('enable/disable', () => { + it('can disable telemetry', () => { + const telemetry = new Telemetry({ config: testConfig }); + expect(telemetry.isEnabled()).toBe(true); + + telemetry.disable(); + expect(telemetry.isEnabled()).toBe(false); }); - // Set up msCrypto (legacy IE11 support) - const getRandomValues = vi.fn((array: Uint8Array) => { - array.set([0x11, 0x22, 0x33, 0x44]); - return array; + it('can enable telemetry', () => { + const telemetry = new Telemetry({ + config: { ...testConfig, enabled: false }, + }); + expect(telemetry.isEnabled()).toBe(false); + + telemetry.enable(); + expect(telemetry.isEnabled()).toBe(true); }); + }); - (globalThis as typeof globalThis & { msCrypto?: Crypto }).msCrypto = { - getRandomValues, - } as Crypto; + describe('error handling', () => { + it('fails silently on fetch error', async () => { + fetchSpy.mockRejectedValue(new Error('Network error')); - const telemetry = new Telemetry(baseConfig); - const id = telemetry.generateId(); + const telemetry = new Telemetry({ config: testConfig }); - expect(getRandomValues).toHaveBeenCalled(); - expect(id.split('-')[1]).toBe('11223344'); + // Should not throw + expect(() => telemetry.trackDocumentOpen('doc-123')).not.toThrow(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + }); }); }); diff --git a/shared/common/Telemetry.ts b/shared/common/Telemetry.ts index b1c2899ea2..f46abec9c0 100644 --- a/shared/common/Telemetry.ts +++ b/shared/common/Telemetry.ts @@ -1,252 +1,87 @@ -type TelemetryAttributes = Record; -type ReadonlyTelemetryRecord = Readonly>; - -/** - * Standard telemetry event names - */ -export const TelemetryEventNames = { - DOCUMENT_OPENED: 'document:opened', - DOCUMENT_PARSED: 'document:parsed', - DOCUMENT_EXPORTED: 'document:exported', - ERROR_OCCURRED: 'error:occurred', - FEATURE_USED: 'feature:used', - CONVERSION_STARTED: 'conversion:started', - CONVERSION_COMPLETED: 'conversion:completed', -} as const; - -/** - * Known telemetry event names (provides autocomplete) - */ -export type KnownTelemetryEvent = (typeof TelemetryEventNames)[keyof typeof TelemetryEventNames]; - /** - * Custom telemetry event name - use for application-specific events - * Branded type to distinguish from known events + * SuperDoc Telemetry - Document Open Tracking + * + * Tracks document opens for usage-based billing. + * Sends immediately on each document open/import. + * Fails silently - never breaks the app. */ -export type CustomTelemetryEvent = string & { readonly __custom?: never }; -/** - * Telemetry event name - either a known event or a custom event - */ -export type TelemetryEventName = KnownTelemetryEvent | CustomTelemetryEvent; - -/** - * Valid statistic categories for tracking - */ -export type StatisticCategory = 'node' | 'unknown' | 'error'; - -/** - * Create a custom telemetry event name - * Use this for application-specific events not covered by TelemetryEventNames - * @param eventName - Custom event name (e.g., 'feature:custom-action') - * @returns Branded custom event name - */ -export function customTelemetryEvent(eventName: string): CustomTelemetryEvent { - return eventName as CustomTelemetryEvent; -} +declare const __APP_VERSION__: string; export interface TelemetryConfig { - readonly licenseKey?: string; - readonly enabled?: boolean; - readonly endpoint?: string; - readonly documentGuid?: string; - readonly documentIdentifier?: string; - readonly superdocVersion: string; + enabled: boolean; + endpoint?: string; + licenseKey?: string | null; + metadata?: Record; } export interface BrowserInfo { - readonly userAgent: string; - readonly currentUrl: string; - readonly hostname: string; - readonly referrerUrl: string; - readonly screenSize: { - readonly width: number; - readonly height: number; + userAgent: string; + currentUrl: string; + hostname: string; + screenSize: { + width: number; + height: number; }; } -export interface Statistics { - nodeTypes: Record; - markTypes: Record; - attributes: Record; - errorCount: number; -} - -export interface FileInfo { - readonly path: string; - readonly name: string; - readonly size?: number; - readonly depth: number; -} - -export interface FileStructure { - totalFiles: number; - maxDepth: number; - totalNodes: number; - files: FileInfo[]; -} - -export interface DocumentInfo { - readonly guid?: string; - readonly identifier?: string; - readonly name: string; - readonly size: number; - readonly lastModified: string | null; - readonly type: string; - readonly internalId?: string; -} - -export interface BaseTelemetryEvent { - id: string; +export interface DocumentOpenEvent { timestamp: string; - sessionId: string; - documentGuid?: string; - documentIdentifier?: string; - superdocVersion: string; - file: DocumentInfo | null; - browser: BrowserInfo; -} - -export interface TelemetryUsageEvent extends BaseTelemetryEvent { - type: 'usage'; - name: string; - properties: TelemetryAttributes; -} - -export interface TelemetryParsingReport extends BaseTelemetryEvent { - type: 'parsing'; - statistics: Statistics; - fileStructure: FileStructure; - unknownElements: UnknownElement[]; - errors: TelemetryError[]; + documentId: string | null; + documentCreatedAt: string | null; } -export type TelemetryPayload = TelemetryUsageEvent | TelemetryParsingReport[]; - -export interface TelemetryError extends ReadonlyTelemetryRecord { - readonly message?: string; - readonly elementName?: string; - readonly attributes?: TelemetryAttributes; - readonly timestamp?: string; +export interface TelemetryPayload { + superdocVersion: string; + browserInfo: BrowserInfo; + metadata?: Record; + events: DocumentOpenEvent[]; } -export interface UnknownElement { - readonly elementName: string; - count: number; - attributes?: TelemetryAttributes; +interface TelemetryOptions { + config: TelemetryConfig; } -/** - * Discriminated union for statistic data based on category - */ -export type StatisticData = - | { - category: 'node'; - elementName: string; - attributes?: TelemetryAttributes; - marks?: Array<{ type: string }>; - } - | { - category: 'unknown'; - elementName: string; - attributes?: TelemetryAttributes; - } - | { - category: 'error'; - message?: string; - elementName?: string; - attributes?: TelemetryAttributes; - timestamp?: string; - [key: string]: unknown; - }; - -function getCrypto(): Crypto | undefined { - if (typeof globalThis === 'undefined') { - return undefined; - } - - const cryptoObj: Crypto | undefined = - (globalThis as typeof globalThis & { crypto?: Crypto }).crypto ?? - (globalThis as typeof globalThis & { msCrypto?: Crypto }).msCrypto; +const DEFAULT_ENDPOINT = 'http://localhost:3051/v1/collect'; +// const DEFAULT_ENDPOINT = 'https://livetest-3---superdoc-telemetry-4yffz5xqqq-uc.a.run.app/v1/collect'; - if (cryptoObj && typeof cryptoObj.getRandomValues === 'function') { - return cryptoObj; +function getSuperdocVersion(): string { + try { + return typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'unknown'; + } catch { + return 'unknown'; } - - return undefined; -} - -function randomBytes(length: number): Uint8Array { - const array = new Uint8Array(length); - const cryptoObj = getCrypto(); - - if (cryptoObj) { - cryptoObj.getRandomValues(array); - return array; - } - - // Final fallback for runtimes without secure entropy (legacy tests, etc.) - for (let i = 0; i < length; i++) { - array[i] = Math.floor(Math.random() * 256); - } - - return array; } export class Telemetry { - enabled: boolean; - documentGuid?: string; - documentIdentifier?: string; - superdocVersion: string; - licenseKey: string; - endpoint: string; - sessionId: string; - statistics: Statistics = { - nodeTypes: {}, - markTypes: {}, - attributes: {}, - errorCount: 0, - }; - unknownElements: UnknownElement[] = []; - errors: TelemetryError[] = []; - fileStructure: FileStructure = { - totalFiles: 0, - maxDepth: 0, - totalNodes: 0, - files: [], - }; - documentInfo: DocumentInfo | null = null; - - static readonly COMMUNITY_LICENSE_KEY = 'community-and-eval-agplv3' as const; - static readonly DEFAULT_ENDPOINT = 'https://ingest.superdoc.dev/v1/collect' as const; - - /** - * Initialize telemetry service - * @param config - Telemetry configuration - */ - constructor(config: TelemetryConfig) { - this.enabled = config.enabled ?? true; - - this.licenseKey = config.licenseKey ?? Telemetry.COMMUNITY_LICENSE_KEY; - this.endpoint = config.endpoint ?? Telemetry.DEFAULT_ENDPOINT; - - // Update naming to match new system - this.documentGuid = config.documentGuid; // Changed from superdocId - this.documentIdentifier = config.documentIdentifier; // New: can be GUID or hash - this.superdocVersion = config.superdocVersion; - this.sessionId = this.generateId(); + private enabled: boolean; + private endpoint: string; + private superdocVersion: string; + private licenseKey: string; + private metadata?: Record; + + constructor(options: TelemetryOptions) { + this.enabled = options.config.enabled; + this.endpoint = options.config.endpoint || DEFAULT_ENDPOINT; + this.licenseKey = options.config.licenseKey || ''; + this.metadata = options.config.metadata; + this.superdocVersion = getSuperdocVersion(); } - /** - * Get browser environment information - * @returns Browser information - */ - getBrowserInfo(): BrowserInfo { + private getBrowserInfo(): BrowserInfo { + if (typeof window === 'undefined') { + return { + userAgent: '', + currentUrl: '', + hostname: '', + screenSize: { width: 0, height: 0 }, + }; + } + return { userAgent: window.navigator.userAgent, currentUrl: window.location.href, hostname: window.location.hostname, - referrerUrl: document.referrer, screenSize: { width: window.screen.width, height: window.screen.height, @@ -255,214 +90,34 @@ export class Telemetry { } /** - * Track document usage event - * @param name - Event name (use TelemetryEventNames for standard events) - * @param properties - Additional properties + * Track a document open event - sends immediately + * @param documentId - Unique document identifier (GUID or hash), or null if unavailable + * @param documentCreatedAt - Document creation timestamp (dcterms:created), or null if unavailable */ - async trackUsage(name: TelemetryEventName, properties: TelemetryAttributes = {}): Promise { + trackDocumentOpen(documentId: string | null, documentCreatedAt: string | null = null): void { if (!this.enabled) return; - const event: TelemetryUsageEvent = { - id: this.generateId(), - type: 'usage', + const event: DocumentOpenEvent = { timestamp: new Date().toISOString(), - sessionId: this.sessionId, - documentGuid: this.documentGuid, // Updated field name - documentIdentifier: this.documentIdentifier, // Include both - superdocVersion: this.superdocVersion, - file: this.documentInfo, - browser: this.getBrowserInfo(), - name, - properties, + documentId, + documentCreatedAt, }; - await this.sendDataToTelemetry(event); + this.sendEvent(event); } /** - * Track parsing statistics (new discriminated union API) - * @param data - Statistic data with category discriminator - */ - trackStatistic(data: StatisticData): void; - /** - * Track parsing statistics (legacy API for backward compatibility) - * @param category - Statistic category - * @param data - Statistic data without category - * @deprecated Use the single-parameter overload with discriminated union instead + * Send event via fetch (fire and forget) */ - trackStatistic(category: 'node', data: Omit, 'category'>): void; - trackStatistic(category: 'unknown', data: Omit, 'category'>): void; - trackStatistic(category: 'error', data: Omit, 'category'>): void; - trackStatistic( - categoryOrData: StatisticCategory | StatisticData, - legacyData?: Omit, - ): void { - // Normalize to new API format - let data: StatisticData; - if (typeof categoryOrData === 'string') { - // Legacy API: trackStatistic(category, data) - data = { ...legacyData, category: categoryOrData } as StatisticData; - } else { - // New API: trackStatistic(data) - data = categoryOrData; - } - - if (data.category === 'node') { - // Type narrowing guarantees elementName exists - this.statistics.nodeTypes[data.elementName] = (this.statistics.nodeTypes[data.elementName] || 0) + 1; - this.fileStructure.totalNodes++; - } else if (data.category === 'unknown') { - // Type narrowing guarantees elementName exists - const addedElement = this.unknownElements.find((e) => e.elementName === data.elementName); - if (addedElement) { - addedElement.count += 1; - addedElement.attributes = { - ...addedElement.attributes, - ...data.attributes, - }; - } else { - this.unknownElements.push({ - elementName: data.elementName, - count: 1, - attributes: data.attributes, - }); - } - } else if (data.category === 'error') { - this.errors.push(data); - this.statistics.errorCount++; - } - - if (data.category === 'node' && data.marks?.length) { - data.marks.forEach((mark) => { - this.statistics.markTypes[mark.type] = (this.statistics.markTypes[mark.type] || 0) + 1; - }); - } - - // Style attributes - if (data.attributes && Object.keys(data.attributes).length) { - const styleAttributes = [ - 'textIndent', - 'textAlign', - 'spacing', - 'lineHeight', - 'indent', - 'list-style-type', - 'listLevel', - 'textStyle', - 'order', - 'lvlText', - 'lvlJc', - 'listNumberingType', - 'numId', - ]; - Object.keys(data.attributes).forEach((attribute) => { - if (!styleAttributes.includes(attribute)) return; - this.statistics.attributes[attribute] = (this.statistics.attributes[attribute] || 0) + 1; - }); - } - } - - /** - * Track file structure - * @param structure - File structure information - * @param fileSource - original file - * @param documentId - document GUID - * @param documentIdentifier - document identifier (GUID or hash) - * @param internalId - document ID from settings.xml - */ - async trackFileStructure( - structure: FileStructure, - fileSource: File, - documentId?: string, - documentIdentifier?: string, - internalId?: string, - ): Promise { - this.fileStructure = structure; - this.documentInfo = await this.processDocument(fileSource, { - guid: documentId, // Updated parameter name - identifier: documentIdentifier, // New parameter - internalId: internalId, - }); - } - - /** - * Process document metadata - * @param file - Document file - * @param options - Additional options - * @returns Document metadata - */ - async processDocument( - file: File | null, - options: { guid?: string; identifier?: string; internalId?: string } = {}, - ): Promise { - if (!file) { - console.warn('Telemetry: missing file source'); - return null; - } - - return { - guid: options.guid, // Updated from 'id' - identifier: options.identifier, // New field - name: file.name, - size: file.size, - lastModified: file.lastModified ? new Date(file.lastModified).toISOString() : null, - type: file.type || 'docx', - internalId: options.internalId, // Microsoft's GUID if present + private async sendEvent(event: DocumentOpenEvent): Promise { + const payload: TelemetryPayload = { + superdocVersion: this.superdocVersion, + browserInfo: this.getBrowserInfo(), + ...(this.metadata && { metadata: this.metadata }), + events: [event], }; - } - - isTelemetryDataChanged(): boolean { - // Empty document case - if (Object.keys(this.statistics.nodeTypes).length <= 1) return false; - - return ( - Object.keys(this.statistics.nodeTypes).length > 0 || - Object.keys(this.statistics.markTypes).length > 0 || - Object.keys(this.statistics.attributes).length > 0 || - this.statistics.errorCount > 0 || - this.fileStructure.totalFiles > 0 || - this.fileStructure.maxDepth > 0 || - this.fileStructure.totalNodes > 0 || - this.fileStructure.files.length > 0 || - this.errors.length > 0 || - this.unknownElements.length > 0 - ); - } - - /** - * Sends current report - * @returns Promise that resolves when report is sent - */ - async sendReport(): Promise { - if (!this.enabled || !this.isTelemetryDataChanged()) return; - - const report: TelemetryParsingReport[] = [ - { - id: this.generateId(), - type: 'parsing', - timestamp: new Date().toISOString(), - sessionId: this.sessionId, - documentGuid: this.documentGuid, - documentIdentifier: this.documentIdentifier, - superdocVersion: this.superdocVersion, - file: this.documentInfo, - browser: this.getBrowserInfo(), - statistics: this.statistics, - fileStructure: this.fileStructure, - unknownElements: this.unknownElements, - errors: this.errors, - }, - ]; - - await this.sendDataToTelemetry(report); - } - /** - * Sends data to the service - * @param data - Payload to send - * @returns Promise that resolves when data is sent - */ - async sendDataToTelemetry(data: TelemetryPayload): Promise { + console.log('[Telemetry] Sending payload:', payload); try { const response = await fetch(this.endpoint, { method: 'POST', @@ -470,52 +125,33 @@ export class Telemetry { 'Content-Type': 'application/json', 'X-License-Key': this.licenseKey, }, - body: JSON.stringify(data), + body: JSON.stringify(payload), + credentials: 'omit', }); - - if (!response.ok) { - throw new Error(`Upload failed: ${response.statusText}`); - } else { - this.resetStatistics(); - } + console.log('[Telemetry] Response status:', response.status); } catch (error) { - console.error('Failed to upload telemetry:', error); + console.error('[Telemetry] Fetch error:', error); } } /** - * Generate unique identifier - * @returns Unique ID - * @private + * Disable telemetry */ - generateId(): string { - const timestamp = Date.now(); - const random = Array.from(randomBytes(4)) - .map((b) => b.toString(16).padStart(2, '0')) - .join(''); - return `${timestamp}-${random}`; + disable(): void { + this.enabled = false; } /** - * Reset statistics + * Enable telemetry */ - resetStatistics(): void { - this.statistics = { - nodeTypes: {}, - markTypes: {}, - attributes: {}, - errorCount: 0, - }; - - this.fileStructure = { - totalFiles: 0, - maxDepth: 0, - totalNodes: 0, - files: [], - }; - - this.unknownElements = []; + enable(): void { + this.enabled = true; + } - this.errors = []; + /** + * Check if telemetry is enabled + */ + isEnabled(): boolean { + return this.enabled; } } diff --git a/shared/common/index.ts b/shared/common/index.ts index 9ce16438df..e95cf1815a 100644 --- a/shared/common/index.ts +++ b/shared/common/index.ts @@ -32,3 +32,7 @@ export { default as vClickOutside } from './helpers/v-click-outside'; // Collaboration/Awareness export * from './collaboration/awareness'; + +// Telemetry +export { Telemetry } from './Telemetry'; +export type { TelemetryConfig, TelemetryPayload, DocumentOpenEvent, BrowserInfo } from './Telemetry'; From a1db605bb2562dbe3fed996b4dd222e3d9dcf6fe Mon Sep 17 00:00:00 2001 From: aorlov Date: Wed, 4 Feb 2026 02:06:03 +0100 Subject: [PATCH 02/11] refactor: streamline document handling and telemetry logging - Simplified telemetry logging in the Editor class by removing unnecessary comments and enhancing debug output for document information. - Removed redundant isNewFile tracking in SuperDoc, allowing automatic handling based on context. --- packages/super-editor/src/core/Editor.ts | 10 +++- .../core/super-converter/SuperConverter.js | 20 ++++++- packages/superdoc/src/core/SuperDoc.js | 1 - .../superdoc/src/core/helpers/file.test.js | 4 +- .../src/dev/components/SuperdocDev.vue | 60 +++++++------------ 5 files changed, 50 insertions(+), 45 deletions(-) diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index bd6f1eab6f..19ea210e25 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -460,8 +460,7 @@ export class Editor extends EventEmitter { } #emitCreateAsync(): void { - // Fire telemetry immediately - don't defer it since the editor might be - // destroyed before the next tick (e.g., in headless/CLI usage) + // Fire telemetry immediately this.#trackDocumentOpen(); setTimeout(() => { @@ -504,6 +503,12 @@ export class Editor extends EventEmitter { try { const documentId = this.getDocumentIdentifier(); const documentCreatedAt = this.converter?.getDocumentCreatedTimestamp?.() || null; + const telemetryPayload = { + documentId: documentId || null, + documentCreatedAt, + isNewFile: this.options.isNewFile, + }; + console.debug('[super-editor] Document info:', telemetryPayload); this.#telemetry.trackDocumentOpen(documentId || null, documentCreatedAt); } catch { // Fail silently - telemetry should never break the app @@ -2176,7 +2181,6 @@ export class Editor extends EventEmitter { /** * Get document identifier using hash(docId + dcterms:created) * Returns a unique identifier that stays the same for the same document - * but changes on "Save As" or when creating from template. */ getDocumentIdentifier(): string | null { return this.converter?.getDocumentIdentifier() || null; diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.js b/packages/super-editor/src/core/super-converter/SuperConverter.js index 93cfc536ca..a476b5fddb 100644 --- a/packages/super-editor/src/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/core/super-converter/SuperConverter.js @@ -688,10 +688,23 @@ class SuperConverter { * For new files created from template, updates dcterms:created to make identifier unique */ resolveDocumentGuid() { + const originalTimestamp = this.getDocumentCreatedTimestamp(); + // If this is a new file created from template, update dcterms:created to current time // This ensures documents created from the same template get unique identifiers if (this.isNewFile) { - this.setDocumentCreatedTimestamp(new Date().toISOString()); + const newTimestamp = new Date().toISOString(); + this.setDocumentCreatedTimestamp(newTimestamp); + console.debug('[super-converter] New file: updated dcterms:created', { + isNewFile: this.isNewFile, + originalTimestamp, + newTimestamp, + }); + } else { + console.debug('[super-converter] Existing file: preserving dcterms:created', { + isNewFile: this.isNewFile, + timestamp: originalTimestamp, + }); } // 1. Check Microsoft's docId (READ ONLY) @@ -741,7 +754,6 @@ class SuperConverter { * * This provides a unique identifier that: * - Stays the same for the same document opened multiple times - * - Changes when a document is "Saved As" (dcterms:created changes) * - Is unique for documents created from template (dcterms:created updated on creation) * - Works with documents that don't have docId (uses generated UUID) * @@ -751,6 +763,10 @@ class SuperConverter { // Generate and cache the hash if not already done if (!this.documentHash) { this.documentHash = this.#generateIdentifierHash(); + console.debug('[super-converter] Generated document identifier:', { + documentId: this.documentHash, + createdAt: this.getDocumentCreatedTimestamp(), + }); } return this.documentHash; diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index 20de6c68f6..b6c80ed628 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -315,7 +315,6 @@ export class SuperDoc extends EventEmitter { type: DOCX, url: this.config.document, name: 'document.docx', - isNewFile: true, }, ]; } else if (hasDocumentFile) { diff --git a/packages/superdoc/src/core/helpers/file.test.js b/packages/superdoc/src/core/helpers/file.test.js index 0b23bf96f6..dc9184322e 100644 --- a/packages/superdoc/src/core/helpers/file.test.js +++ b/packages/superdoc/src/core/helpers/file.test.js @@ -56,8 +56,8 @@ describe('normalizeDocumentEntry', () => { name: 'doc.docx', type: DOCX, }); - // isNewFile is not set by normalizeDocumentEntry for direct files - // It should be set by the caller based on context + // isNewFile is not set by normalizeDocumentEntry - the Editor determines this + // automatically based on whether content was provided expect(out.isNewFile).toBeUndefined(); expect(out.data).toBeInstanceOf(File); expect(out.data).toBe(f); diff --git a/packages/superdoc/src/dev/components/SuperdocDev.vue b/packages/superdoc/src/dev/components/SuperdocDev.vue index 7475019233..ec18b78ffc 100644 --- a/packages/superdoc/src/dev/components/SuperdocDev.vue +++ b/packages/superdoc/src/dev/components/SuperdocDev.vue @@ -32,13 +32,6 @@ const activeEditor = shallowRef(null); const title = ref('initial title'); const currentFile = ref(null); -// Note: isNewFile is tracked here because the dev shell manually loads blank.docx on mount. -// In production, consumers don't need to track this - just omit the `document` config for new -// documents and SuperDoc will automatically create a blank document with isNewFile: true. -// This flag only matters when explicitly providing a document to distinguish between: -// - New document from template (isNewFile: true) → updates dcterms:created timestamp -// - Imported existing document (isNewFile: false) → preserves original timestamp -const isNewFile = ref(false); const commentsPanel = ref(null); const showCommentsPanel = ref(true); const sidebarInstanceKey = ref(0); @@ -102,9 +95,8 @@ const commentPermissionResolver = ({ permission, comment, defaultDecision, curre return defaultDecision; }; -const handleNewFile = async (file, isNew = false) => { +const handleNewFile = async (file) => { uploadedFileName.value = file?.name || ''; - isNewFile.value = isNew; // Generate a file url const url = URL.createObjectURL(file); @@ -115,10 +107,8 @@ const handleNewFile = async (file, isNew = false) => { if (isMarkdown || isHtml) { // For text-based files, read the content and use a blank DOCX as base - // These are considered "new" documents since they use the blank template const content = await readFileAsText(file); currentFile.value = await getFileObject(BlankDOCX, 'blank.docx', DOCX); - isNewFile.value = true; // Using blank template as base = new document // Store the content to be passed to SuperDoc if (isMarkdown) { @@ -129,11 +119,6 @@ const handleNewFile = async (file, isNew = false) => { } else { // For binary files (DOCX, PDF), use as-is currentFile.value = await getFileObject(url, file.name, file.type); - // Only set to false if not explicitly passed as new file - // (onMounted passes isNew=true for the initial blank.docx) - if (!isNew) { - isNewFile.value = false; // Imported file = not new - } } nextTick(() => { @@ -168,21 +153,22 @@ const init = async () => { // eslint-disable-next-line no-unused-vars const testDocumentId = 'doc123'; - // Prepare document config with content if available - // isNewFile should only be true for blank/new documents, not imported files - // This ensures imported documents keep their original dcterms:created timestamp - const documentConfig = { - data: currentFile.value, - id: testId, - isNewFile: isNewFile.value, - }; - - // Add markdown/HTML content if present - if (currentFile.value.markdownContent) { - documentConfig.markdown = currentFile.value.markdownContent; - } - if (currentFile.value.htmlContent) { - documentConfig.html = currentFile.value.htmlContent; + // Prepare document config only if a file was uploaded + // If no file, SuperDoc will automatically create a blank document + let documentConfig = null; + if (currentFile.value) { + documentConfig = { + data: currentFile.value, + id: testId, + }; + + // Add markdown/HTML content if present + if (currentFile.value.markdownContent) { + documentConfig.markdown = currentFile.value.markdownContent; + } + if (currentFile.value.htmlContent) { + documentConfig.html = currentFile.value.htmlContent; + } } const config = { @@ -194,7 +180,7 @@ const init = async () => { documentMode: 'editing', licenseKey: 'community-and-eval-agplv3', telemetry: { - enabled: false + enabled: true }, comments: { visible: true, @@ -221,12 +207,12 @@ const init = async () => { { name: 'Nick Bernal', email: 'nick@harbourshare.com', access: 'internal' }, { name: 'Eric Doversberger', email: 'eric@harbourshare.com', access: 'external' }, ], - document: documentConfig, + // Only pass document config if a file was uploaded, otherwise SuperDoc creates blank + ...(documentConfig ? { document: documentConfig } : {}), // documents: [ // { // data: currentFile.value, // id: testId, - // isNewFile: true, // }, // ], // cspNonce: 'testnonce123', @@ -546,8 +532,8 @@ onMounted(async () => { console.log('[collab] Provider synced, initializing SuperDoc'); } - const blankFile = await getFileObject(BlankDOCX, 'test.docx', DOCX); - handleNewFile(blankFile, true); // true = new document, needs unique timestamp + // Initialize SuperDoc - it will automatically create a blank document + init(); }); onBeforeUnmount(() => { @@ -795,7 +781,7 @@ if (scrollTestMode.value) {
-
+
From f6ef18cee28721953e883faf3ceb74e1710714fe Mon Sep 17 00:00:00 2001 From: aorlov Date: Wed, 4 Feb 2026 02:28:01 +0100 Subject: [PATCH 03/11] feat: enhance telemetry license key handling - Introduced default community license key in the Editor class for telemetry initialization. - Updated `initTelemetry` function to use the default license key when none is provided. - Added tests to verify behavior with default and custom license keys. - Improved documentation for the community license key usage. --- .../src/core/Editor.telemetry.test.ts | 28 ++++++++++++------- packages/super-editor/src/core/Editor.ts | 17 +++++++++-- shared/common/Telemetry.ts | 3 +- 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/packages/super-editor/src/core/Editor.telemetry.test.ts b/packages/super-editor/src/core/Editor.telemetry.test.ts index dfe573b849..014753fc80 100644 --- a/packages/super-editor/src/core/Editor.telemetry.test.ts +++ b/packages/super-editor/src/core/Editor.telemetry.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { Telemetry } from '@superdoc/common'; +import { Editor } from './Editor.js'; // Mock the Telemetry class to verify it's called correctly vi.mock('@superdoc/common', () => ({ @@ -15,9 +16,11 @@ vi.mock('@superdoc/common', () => ({ // This mirrors the #initTelemetry method in Editor.ts function initTelemetry(options: { telemetry?: { enabled: boolean; endpoint?: string; metadata?: Record } | null; - licenseKey?: string | null; + licenseKey?: string; }): Telemetry | null { - const { telemetry: telemetryConfig, licenseKey } = options; + // Use default license key if not provided (mirrors Editor.options default) + const licenseKey = options.licenseKey ?? Editor.COMMUNITY_LICENSE_KEY; + const { telemetry: telemetryConfig } = options; // Skip if telemetry is not enabled if (!telemetryConfig?.enabled) { @@ -29,7 +32,7 @@ function initTelemetry(options: { config: { enabled: true, endpoint: telemetryConfig.endpoint, - licenseKey: licenseKey || undefined, + licenseKey, metadata: telemetryConfig.metadata, }, }); @@ -95,8 +98,8 @@ describe('Editor Telemetry Integration', () => { }); }); - describe('telemetry without license key', () => { - it('creates Telemetry instance when enabled without license key', () => { + describe('default license key', () => { + it('uses default community license key when licenseKey is not provided', () => { const result = initTelemetry({ telemetry: { enabled: true }, }); @@ -107,16 +110,21 @@ describe('Editor Telemetry Integration', () => { config: { enabled: true, endpoint: undefined, - licenseKey: undefined, + licenseKey: Editor.COMMUNITY_LICENSE_KEY, metadata: undefined, }, }); }); - it('creates Telemetry instance when license key is null', () => { + it('uses default community license key value "community-and-eval-agplv3"', () => { + expect(Editor.COMMUNITY_LICENSE_KEY).toBe('community-and-eval-agplv3'); + }); + + it('overrides default license key when custom key is provided', () => { + const customKey = 'my-custom-license-key'; const result = initTelemetry({ telemetry: { enabled: true }, - licenseKey: null, + licenseKey: customKey, }); expect(result).not.toBeNull(); @@ -124,7 +132,7 @@ describe('Editor Telemetry Integration', () => { config: { enabled: true, endpoint: undefined, - licenseKey: undefined, + licenseKey: customKey, metadata: undefined, }, }); @@ -187,7 +195,7 @@ describe('Editor Telemetry Integration', () => { config: { enabled: true, endpoint: undefined, - licenseKey: undefined, + licenseKey: Editor.COMMUNITY_LICENSE_KEY, metadata, }, }); diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index 19ea210e25..783f6cceeb 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -148,10 +148,23 @@ export interface SaveOptions { */ export type ExportOptions = SaveOptions; +/** + * Community license key for AGPLv3 / evaluation usage. + * This is the default license key + */ +const COMMUNITY_LICENSE_KEY = 'community-and-eval-agplv3'; + /** * Main editor class that manages document state, extensions, and user interactions */ export class Editor extends EventEmitter { + /** + * Community license key for AGPLv3 / evaluation usage. + * This is the default license key - you don't need to set it explicitly + * unless you have a commercial license to override it. + */ + static readonly COMMUNITY_LICENSE_KEY = COMMUNITY_LICENSE_KEY; + /** * Command service for handling editor commands */ @@ -331,8 +344,8 @@ export class Editor extends EventEmitter { // header/footer editors may have parent(main) editor set parentEditor: null, - // License key for billing - licenseKey: null, + // License key (defaults to community license) + licenseKey: COMMUNITY_LICENSE_KEY, // Telemetry configuration telemetry: null, diff --git a/shared/common/Telemetry.ts b/shared/common/Telemetry.ts index f46abec9c0..c391e194f1 100644 --- a/shared/common/Telemetry.ts +++ b/shared/common/Telemetry.ts @@ -42,8 +42,7 @@ interface TelemetryOptions { config: TelemetryConfig; } -const DEFAULT_ENDPOINT = 'http://localhost:3051/v1/collect'; -// const DEFAULT_ENDPOINT = 'https://livetest-3---superdoc-telemetry-4yffz5xqqq-uc.a.run.app/v1/collect'; +const DEFAULT_ENDPOINT = 'https://livetest-3---superdoc-telemetry-4yffz5xqqq-uc.a.run.app/v1/collect'; function getSuperdocVersion(): string { try { From 372512b8f1a99d8ebf2d75ae14e4a164a3c49b40 Mon Sep 17 00:00:00 2001 From: aorlov Date: Wed, 4 Feb 2026 02:39:07 +0100 Subject: [PATCH 04/11] test: update telemetry test endpoint URL --- shared/common/Telemetry.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shared/common/Telemetry.test.ts b/shared/common/Telemetry.test.ts index 9093a2e62c..bde69285f3 100644 --- a/shared/common/Telemetry.test.ts +++ b/shared/common/Telemetry.test.ts @@ -154,9 +154,9 @@ describe('Telemetry', () => { await new Promise((resolve) => setTimeout(resolve, 0)); expect(fetchSpy).toHaveBeenCalledTimes(1); - // Default endpoint is http://localhost:3051/v1/collect + // Default endpoint is https://livetest-3---superdoc-telemetry-4yffz5xqqq-uc.a.run.app/v1/collect expect(fetchSpy).toHaveBeenCalledWith( - 'http://localhost:3051/v1/collect', + 'https://livetest-3---superdoc-telemetry-4yffz5xqqq-uc.a.run.app/v1/collect', expect.objectContaining({ method: 'POST' }), ); }); From 1494cefc2a6470b564bf1721087ee5738a241b28 Mon Sep 17 00:00:00 2001 From: aorlov Date: Thu, 5 Feb 2026 02:04:46 +0100 Subject: [PATCH 05/11] feat: enhance document tracking and identifier generation - Introduced a guard flag to prevent double-tracking of document opens in the Editor class. - Updated document identifier generation to be asynchronous, ensuring metadata is available before tracking. - Enhanced SuperConverter to manage document timestamps and unique identifiers more effectively. - Added new utility methods for generating Word-compatible timestamps and content hashes. - Improved test coverage for document identifier resolution and timestamp handling in various scenarios. --- packages/super-editor/src/core/Editor.ts | 45 +- .../core/super-converter/SuperConverter.js | 178 +++++--- .../super-converter/SuperConverter.test.js | 387 +++++++++++++++++- 3 files changed, 524 insertions(+), 86 deletions(-) diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index 783f6cceeb..05a3dfd705 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -259,6 +259,11 @@ export class Editor extends EventEmitter { */ #telemetry: Telemetry | null = null; + /** + * Guard flag to prevent double-tracking document open + */ + #documentOpenTracked = false; + options: EditorOptions = { element: null, selector: null, @@ -472,8 +477,10 @@ export class Editor extends EventEmitter { return this.options.document ?? this.options.mockDocument ?? (canUseDOM() ? document : null); } - #emitCreateAsync(): void { - // Fire telemetry immediately + async #emitCreateAsync(): Promise { + // Ensure document metadata (GUID, timestamp) is generated on import + // This must happen regardless of telemetry being enabled + await this.getDocumentIdentifier(); this.#trackDocumentOpen(); setTimeout(() => { @@ -510,19 +517,19 @@ export class Editor extends EventEmitter { /** * Track document open event for telemetry */ - #trackDocumentOpen(): void { - if (!this.#telemetry) return; + async #trackDocumentOpen(): Promise { + if (!this.#telemetry || this.#documentOpenTracked) return; try { - const documentId = this.getDocumentIdentifier(); + const documentId = await this.getDocumentIdentifier(); const documentCreatedAt = this.converter?.getDocumentCreatedTimestamp?.() || null; - const telemetryPayload = { - documentId: documentId || null, + console.debug('[super-editor] Document info:', { + documentId, documentCreatedAt, isNewFile: this.options.isNewFile, - }; - console.debug('[super-editor] Document info:', telemetryPayload); - this.#telemetry.trackDocumentOpen(documentId || null, documentCreatedAt); + }); + this.#telemetry.trackDocumentOpen(documentId, documentCreatedAt); + this.#documentOpenTracked = true; } catch { // Fail silently - telemetry should never break the app } @@ -1057,9 +1064,11 @@ export class Editor extends EventEmitter { } } - mount(el: HTMLElement | null): void { + async mount(el: HTMLElement | null): Promise { this.#createView(el); + await this.getDocumentIdentifier(); + setTimeout(() => { if (this.isDestroyed) return; this.emit('create', { editor: this }); @@ -2192,11 +2201,11 @@ export class Editor extends EventEmitter { } /** - * Get document identifier using hash(docId + dcterms:created) - * Returns a unique identifier that stays the same for the same document + * Get document unique identifier (async) + * Returns a stable identifier for the document (identifierHash or contentHash) */ - getDocumentIdentifier(): string | null { - return this.converter?.getDocumentIdentifier() || null; + async getDocumentIdentifier(): Promise { + return (await this.converter?.getDocumentIdentifier()) || null; } /** @@ -2594,6 +2603,11 @@ export class Editor extends EventEmitter { const numberingData = this.converter.convertedXml['word/numbering.xml']; const numbering = this.converter.schemaToXml(numberingData.elements[0]); + + // Export core.xml (contains dcterms:created timestamp) + const coreXmlData = this.converter.convertedXml['docProps/core.xml']; + const coreXml = coreXmlData?.elements?.[0] ? this.converter.schemaToXml(coreXmlData.elements[0]) : null; + const updatedDocs: Record = { ...this.options.customUpdatedFiles, 'word/document.xml': String(documentXml), @@ -2604,6 +2618,7 @@ export class Editor extends EventEmitter { // Replace & with & in styles.xml as DOCX viewers can't handle it 'word/styles.xml': String(styles).replace(/&/gi, '&'), ...updatedHeadersFooters, + ...(coreXml ? { 'docProps/core.xml': String(coreXml) } : {}), }; if (hasCustomSettings) { diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.js b/packages/super-editor/src/core/super-converter/SuperConverter.js index a476b5fddb..831093ed64 100644 --- a/packages/super-editor/src/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/core/super-converter/SuperConverter.js @@ -241,11 +241,11 @@ class SuperConverter { this.documentId = params?.documentId || null; // Document identification - this.documentGuid = null; // Permanent GUID for modified documents - this.documentHash = null; // Temporary hash for unmodified documents + this.documentGuid = null; // Permanent GUID (from MS docId, custom property, or generated) + this.documentUniqueIdentifier = null; // Final identifier (identifierHash or contentHash) this.documentModified = false; // Track if document has been edited - // Track if this is a new file created from template + // Track if this is a new file created from blank template this.isNewFile = params?.isNewFile || false; // Parse the initial XML, if provided @@ -591,6 +591,16 @@ class SuperConverter { return SuperConverter.setStoredCustomProperty(docx, 'SuperdocVersion', version, false); } + /** + * Generate a Word-compatible timestamp (truncated to minute precision like MS Word) + * @returns {string} Timestamp in YYYY-MM-DDTHH:MM:00Z format + */ + static generateWordTimestamp() { + const date = new Date(); + date.setSeconds(0, 0); + return date.toISOString().split('.')[0] + 'Z'; + } + /** * Get the dcterms:created timestamp from the already-parsed core.xml * @returns {string|null} The created timestamp in ISO format, or null if not found @@ -622,14 +632,28 @@ class SuperConverter { const coreProps = coreXml.elements?.find( (el) => el.name === 'cp:coreProperties' || SuperConverter._matchesElementName(el.name, 'coreProperties'), ); - if (!coreProps?.elements) return; + if (!coreProps) return; - const createdElement = coreProps.elements.find( + // Initialize elements array if missing + if (!coreProps.elements) { + coreProps.elements = []; + } + + let createdElement = coreProps.elements.find( (el) => el.name === 'dcterms:created' || SuperConverter._matchesElementName(el.name, 'created'), ); if (createdElement?.elements?.[0]) { createdElement.elements[0].text = timestamp; + } else { + // Create the element if it doesn't exist + createdElement = { + type: 'element', + name: 'dcterms:created', + attributes: { 'xsi:type': 'dcterms:W3CDTF' }, + elements: [{ type: 'text', text: timestamp }], + }; + coreProps.elements.push(createdElement); } } @@ -685,41 +709,30 @@ class SuperConverter { /** * Resolve existing document GUID (synchronous) - * For new files created from template, updates dcterms:created to make identifier unique + * For new files: reads existing GUID and sets fresh timestamp + * For imported files: reads existing GUIDs only */ resolveDocumentGuid() { - const originalTimestamp = this.getDocumentCreatedTimestamp(); - - // If this is a new file created from template, update dcterms:created to current time - // This ensures documents created from the same template get unique identifiers - if (this.isNewFile) { - const newTimestamp = new Date().toISOString(); - this.setDocumentCreatedTimestamp(newTimestamp); - console.debug('[super-converter] New file: updated dcterms:created', { - isNewFile: this.isNewFile, - originalTimestamp, - newTimestamp, - }); - } else { - console.debug('[super-converter] Existing file: preserving dcterms:created', { - isNewFile: this.isNewFile, - timestamp: originalTimestamp, - }); - } - // 1. Check Microsoft's docId (READ ONLY) const microsoftGuid = this.getMicrosoftDocId(); if (microsoftGuid) { this.documentGuid = microsoftGuid; - return; + } else { + // 2. Check our custom property + const customGuid = SuperConverter.getStoredCustomProperty(this.docx, 'DocumentGuid'); + if (customGuid) { + this.documentGuid = customGuid; + } } - // 2. Check our custom property - const customGuid = SuperConverter.getStoredCustomProperty(this.docx, 'DocumentGuid'); - if (customGuid) { - this.documentGuid = customGuid; + // NEW FILE: set fresh timestamp (ensures unique identifier for each new doc from template) + if (this.isNewFile) { + this.setDocumentCreatedTimestamp(SuperConverter.generateWordTimestamp()); + console.debug('[super-converter] New file: set fresh timestamp', { + documentGuid: this.documentGuid, + createdAt: this.getDocumentCreatedTimestamp(), + }); } - // Don't generate hash here - do it lazily when needed } /** @@ -734,53 +747,114 @@ class SuperConverter { } /** - * Generate document identifier hash from docId and created timestamp + * Generate identifier hash from documentGuid and dcterms:created * Uses CRC32 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() { - const docId = this.documentGuid || uuidv4(); - const created = this.getDocumentCreatedTimestamp() || new Date().toISOString(); - - const combined = `${docId}|${created}`; + const combined = `${this.documentGuid}|${this.getDocumentCreatedTimestamp()}`; const buffer = Buffer.from(combined, 'utf8'); const hash = crc32(buffer); - return `HASH-${hash.toString('hex').toUpperCase()}`; } /** - * Get document identifier using hash(docId + dcterms:created) + * Generate content hash from file bytes + * Uses CRC32 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 { + return `HASH-${uuidv4().replace(/-/g, '').substring(0, 8).toUpperCase()}`; + } + + const hash = crc32(buffer); + return `HASH-${hash.toString('hex').toUpperCase()}`; + } catch (e) { + console.warn('[super-converter] Could not generate content hash:', e); + return `HASH-${uuidv4().replace(/-/g, '').substring(0, 8).toUpperCase()}`; + } + } + + /** + * Get document unique identifier (async) + * + * For new files (isNewFile: true): + * - GUID and timestamp already set in resolveDocumentGuid() + * - Returns identifierHash(guid|timestamp) * - * This provides a unique identifier that: - * - Stays the same for the same document opened multiple times - * - Is unique for documents created from template (dcterms:created updated on creation) - * - Works with documents that don't have docId (uses generated UUID) + * For imported files (isNewFile: false): + * - If both documentGuid and dcterms:created exist: returns identifierHash + * - Otherwise: returns contentHash and generates missing metadata for future exports * - * @returns {string} Document identifier + * @returns {Promise} Document unique identifier */ - getDocumentIdentifier() { - // Generate and cache the hash if not already done - if (!this.documentHash) { - this.documentHash = this.#generateIdentifierHash(); - console.debug('[super-converter] Generated document identifier:', { - documentId: this.documentHash, + async getDocumentIdentifier() { + // Return cached identifier if already computed + if (this.documentUniqueIdentifier) { + return this.documentUniqueIdentifier; + } + + // Check what metadata we have (for new files, both are set in resolveDocumentGuid) + const hasGuid = Boolean(this.documentGuid); + const hasTimestamp = Boolean(this.getDocumentCreatedTimestamp()); + + if (hasGuid && hasTimestamp) { + // Both exist: use identifierHash + this.documentUniqueIdentifier = this.#generateIdentifierHash(); + console.debug('[super-converter] Document identifier (metadata hash):', { + documentUniqueIdentifier: this.documentUniqueIdentifier, + documentGuid: this.documentGuid, + createdAt: this.getDocumentCreatedTimestamp(), + isNewFile: this.isNewFile, + }); + } 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 + if (!hasGuid) { + this.documentGuid = uuidv4(); + } + if (!hasTimestamp) { + this.setDocumentCreatedTimestamp(SuperConverter.generateWordTimestamp()); + } + this.documentModified = true; // Ensures metadata is saved on export + this.documentUniqueIdentifier = await this.#generateContentHash(); + console.debug('[super-converter] Document identifier (content hash):', { + documentUniqueIdentifier: this.documentUniqueIdentifier, + documentGuid: this.documentGuid, createdAt: this.getDocumentCreatedTimestamp(), + reason: !hasGuid && !hasTimestamp ? 'missing both' : !hasGuid ? 'missing GUID' : 'missing timestamp', }); } - return this.documentHash; + return this.documentUniqueIdentifier; } /** - * Promote from hash to GUID on first edit + * Promote to GUID on first edit (for documents that didn't have one) */ promoteToGuid() { if (this.documentGuid) return this.documentGuid; this.documentGuid = this.getMicrosoftDocId() || uuidv4(); this.documentModified = true; - this.documentHash = null; // Clear temporary hash + this.documentUniqueIdentifier = null; // Clear cached identifier // Note: GUID is stored to custom properties during export to avoid // unnecessary XML modifications if the document is never saved diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.test.js b/packages/super-editor/src/core/super-converter/SuperConverter.test.js index 07c13c6a99..b37f827340 100644 --- a/packages/super-editor/src/core/super-converter/SuperConverter.test.js +++ b/packages/super-editor/src/core/super-converter/SuperConverter.test.js @@ -6,11 +6,6 @@ vi.mock('uuid', () => ({ v4: vi.fn(() => 'test-uuid-1234'), })); -function hasTemporaryId(converter) { - // Has temporary ID if no GUID but has hash (or could generate one) - return !converter.documentGuid && !!(converter.documentHash || converter.fileSource); -} - describe('SuperConverter Document GUID', () => { let mockDocx; let mockCustomXml; @@ -49,14 +44,13 @@ describe('SuperConverter Document GUID', () => { describe('Document Identifier Resolution', () => { it('prioritizes Microsoft docId from settings.xml', () => { mockSettingsXml.content = ` - `; const converter = new SuperConverter({ docx: mockDocx }); expect(converter.getDocumentGuid()).toBe('MICROSOFT-GUID-123'); - expect(hasTemporaryId(converter)).toBe(false); }); it('uses custom DocumentGuid when no Microsoft GUID exists', () => { @@ -74,44 +68,399 @@ describe('SuperConverter Document GUID', () => { const converter = new SuperConverter({ docx: customDocx }); expect(converter.getDocumentGuid()).toBe('CUSTOM-GUID-456'); - expect(hasTemporaryId(converter)).toBe(false); }); - it('generates hash for unmodified document without GUID', async () => { + it('generates content hash and assigns GUID for document without GUID/timestamp', async () => { const fileSource = Buffer.from('test file content'); const converter = new SuperConverter({ docx: mockDocx, fileSource, }); - // getDocumentIdentifier is now async + // Before calling getDocumentIdentifier, no GUID is assigned + expect(converter.getDocumentGuid()).toBeNull(); + + // getDocumentIdentifier assigns GUID and returns content hash (since no timestamp) const identifier = await converter.getDocumentIdentifier(); expect(identifier).toMatch(/^HASH-/); - expect(hasTemporaryId(converter)).toBe(true); + + // GUID is now assigned (for persistence on export) + expect(converter.getDocumentGuid()).toBe('test-uuid-1234'); + expect(converter.documentModified).toBe(true); + }); + + it('new file: sets fresh timestamp on init', () => { + const mockCoreXml = { + name: 'docProps/core.xml', + content: ` + + `, + }; + + const converter = new SuperConverter({ + docx: [...mockDocx, mockCoreXml], + isNewFile: true, + }); + + // New file should have timestamp set immediately + const timestamp = converter.getDocumentCreatedTimestamp(); + expect(timestamp).not.toBeNull(); + expect(timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/); + }); + + it('new file: uses identifier hash (GUID + timestamp)', async () => { + const mockCoreXml = { + name: 'docProps/core.xml', + content: ` + + `, + }; + const mockSettingsWithGuid = { + name: 'word/settings.xml', + content: ` + + + `, + }; + + const converter = new SuperConverter({ + docx: [mockCustomXml, mockSettingsWithGuid, mockDocx[2], mockCoreXml], + isNewFile: true, + }); + + // Has both GUID and timestamp, so should use identifier hash + const identifier = await converter.getDocumentIdentifier(); + expect(identifier).toMatch(/^HASH-[A-F0-9]+$/); + expect(converter.documentModified).toBeFalsy(); + }); + + it('imported file with GUID and timestamp: uses identifier hash', async () => { + const mockCoreXmlWithTimestamp = { + name: 'docProps/core.xml', + content: ` + + 2024-01-15T10:30:00Z + `, + }; + const mockSettingsWithGuid = { + name: 'word/settings.xml', + content: ` + + + `, + }; + + const converter = new SuperConverter({ + docx: [mockCustomXml, mockSettingsWithGuid, mockDocx[2], mockCoreXmlWithTimestamp], + isNewFile: false, + }); + + const identifier = await converter.getDocumentIdentifier(); + expect(identifier).toMatch(/^HASH-[A-F0-9]+$/); + expect(converter.getDocumentGuid()).toBe('EXISTING-GUID-123'); + expect(converter.getDocumentCreatedTimestamp()).toBe('2024-01-15T10:30:00Z'); + expect(converter.documentModified).toBeFalsy(); + }); + + it('imported file with GUID but no timestamp: uses content hash and generates timestamp', async () => { + const mockCoreXmlEmpty = { + name: 'docProps/core.xml', + content: ` + + `, + }; + const mockSettingsWithGuid = { + name: 'word/settings.xml', + content: ` + + + `, + }; + const fileSource = Buffer.from('test file content for guid no timestamp'); + + const converter = new SuperConverter({ + docx: [mockCustomXml, mockSettingsWithGuid, mockDocx[2], mockCoreXmlEmpty], + fileSource, + isNewFile: false, + }); + + // Has GUID but no timestamp + expect(converter.getDocumentGuid()).toBe('EXISTING-GUID-456'); + expect(converter.getDocumentCreatedTimestamp()).toBeNull(); + + const identifier = await converter.getDocumentIdentifier(); + expect(identifier).toMatch(/^HASH-[A-F0-9]+$/); + + // Timestamp should now be generated + expect(converter.getDocumentCreatedTimestamp()).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/); + expect(converter.documentModified).toBe(true); + }); + + it('imported file with timestamp but no GUID: uses content hash and generates GUID', async () => { + const mockCoreXmlWithTimestamp = { + name: 'docProps/core.xml', + content: ` + + 2024-01-15T10:30:00Z + `, + }; + const fileSource = Buffer.from('test file content for timestamp no guid'); + + const converter = new SuperConverter({ + docx: [mockCustomXml, mockSettingsXml, mockDocx[2], mockCoreXmlWithTimestamp], + fileSource, + isNewFile: false, + }); + + // Has timestamp but no GUID expect(converter.getDocumentGuid()).toBeNull(); + expect(converter.getDocumentCreatedTimestamp()).toBe('2024-01-15T10:30:00Z'); + + const identifier = await converter.getDocumentIdentifier(); + expect(identifier).toMatch(/^HASH-[A-F0-9]+$/); + + // GUID should now be generated + expect(converter.getDocumentGuid()).toBe('test-uuid-1234'); + expect(converter.documentModified).toBe(true); + }); + + it('imported file with neither GUID nor timestamp: uses content hash and generates both', async () => { + const mockCoreXmlEmpty = { + name: 'docProps/core.xml', + content: ` + + `, + }; + const fileSource = Buffer.from('test file content for neither'); + + const converter = new SuperConverter({ + docx: [mockCustomXml, mockSettingsXml, mockDocx[2], mockCoreXmlEmpty], + fileSource, + isNewFile: false, + }); + + // Has neither + expect(converter.getDocumentGuid()).toBeNull(); + expect(converter.getDocumentCreatedTimestamp()).toBeNull(); + + const identifier = await converter.getDocumentIdentifier(); + expect(identifier).toMatch(/^HASH-[A-F0-9]+$/); + + // Both should now be generated + expect(converter.getDocumentGuid()).toBe('test-uuid-1234'); + expect(converter.getDocumentCreatedTimestamp()).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/); + expect(converter.documentModified).toBe(true); + }); + + it('content hash is stable for same file content', async () => { + const fileSource = Buffer.from('identical file content'); + const mockCoreXmlEmpty = { + name: 'docProps/core.xml', + content: ` + + `, + }; + + const converter1 = new SuperConverter({ + docx: [mockCustomXml, mockSettingsXml, mockDocx[2], mockCoreXmlEmpty], + fileSource, + isNewFile: false, + }); + + const converter2 = new SuperConverter({ + docx: [mockCustomXml, mockSettingsXml, mockDocx[2], mockCoreXmlEmpty], + fileSource, + isNewFile: false, + }); + + const identifier1 = await converter1.getDocumentIdentifier(); + const identifier2 = await converter2.getDocumentIdentifier(); + + expect(identifier1).toBe(identifier2); + }); + + it('identifier hash is stable for same GUID and timestamp', async () => { + const mockCoreXmlWithTimestamp = { + name: 'docProps/core.xml', + content: ` + + 2024-01-15T10:30:00Z + `, + }; + const mockSettingsWithGuid = { + name: 'word/settings.xml', + content: ` + + + `, + }; + + const converter1 = new SuperConverter({ + docx: [mockCustomXml, mockSettingsWithGuid, mockDocx[2], mockCoreXmlWithTimestamp], + }); + + const converter2 = new SuperConverter({ + docx: [mockCustomXml, mockSettingsWithGuid, mockDocx[2], mockCoreXmlWithTimestamp], + }); + + const identifier1 = await converter1.getDocumentIdentifier(); + const identifier2 = await converter2.getDocumentIdentifier(); + + expect(identifier1).toBe(identifier2); + }); + }); + + describe('Document Timestamp Methods', () => { + it('getDocumentCreatedTimestamp returns timestamp when present', () => { + const mockCoreXmlWithTimestamp = { + name: 'docProps/core.xml', + content: ` + + 2024-06-15T14:30:00Z + `, + }; + + const converter = new SuperConverter({ + docx: [...mockDocx, mockCoreXmlWithTimestamp], + }); + + expect(converter.getDocumentCreatedTimestamp()).toBe('2024-06-15T14:30:00Z'); + }); + + it('getDocumentCreatedTimestamp returns null when not present', () => { + const mockCoreXmlEmpty = { + name: 'docProps/core.xml', + content: ` + + `, + }; + + const converter = new SuperConverter({ + docx: [...mockDocx, mockCoreXmlEmpty], + }); + + expect(converter.getDocumentCreatedTimestamp()).toBeNull(); + }); + + it('getDocumentCreatedTimestamp returns null when core.xml is missing', () => { + const converter = new SuperConverter({ + docx: mockDocx, + }); + + expect(converter.getDocumentCreatedTimestamp()).toBeNull(); + }); + + it('setDocumentCreatedTimestamp updates existing timestamp', () => { + const mockCoreXmlWithTimestamp = { + name: 'docProps/core.xml', + content: ` + + 2024-01-01T00:00:00Z + `, + }; + + const converter = new SuperConverter({ + docx: [...mockDocx, mockCoreXmlWithTimestamp], + }); + + expect(converter.getDocumentCreatedTimestamp()).toBe('2024-01-01T00:00:00Z'); + + converter.setDocumentCreatedTimestamp('2024-12-25T12:00:00Z'); + + expect(converter.getDocumentCreatedTimestamp()).toBe('2024-12-25T12:00:00Z'); + }); + + it('setDocumentCreatedTimestamp creates element when dcterms:created is missing', () => { + const mockCoreXmlEmpty = { + name: 'docProps/core.xml', + content: ` + + `, + }; + + const converter = new SuperConverter({ + docx: [...mockDocx, mockCoreXmlEmpty], + }); + + expect(converter.getDocumentCreatedTimestamp()).toBeNull(); + + converter.setDocumentCreatedTimestamp('2024-07-04T09:00:00Z'); + + expect(converter.getDocumentCreatedTimestamp()).toBe('2024-07-04T09:00:00Z'); + }); + + it('setDocumentCreatedTimestamp creates elements array when missing', () => { + const mockCoreXmlNoElements = { + name: 'docProps/core.xml', + content: ` + `, + }; + + const converter = new SuperConverter({ + docx: [...mockDocx, mockCoreXmlNoElements], + }); + + expect(converter.getDocumentCreatedTimestamp()).toBeNull(); + + converter.setDocumentCreatedTimestamp('2024-08-15T16:30:00Z'); + + expect(converter.getDocumentCreatedTimestamp()).toBe('2024-08-15T16:30:00Z'); + }); + + it('generateWordTimestamp returns correct format without milliseconds', () => { + const timestamp = SuperConverter.generateWordTimestamp(); + + // Should match YYYY-MM-DDTHH:MM:SSZ format (no milliseconds) + expect(timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/); + + // Seconds should be 00 (truncated to minute precision) + expect(timestamp).toMatch(/:00Z$/); }); }); describe('GUID Promotion', () => { - it('promotes hash to GUID when document is modified', async () => { + it('promoteToGuid returns existing GUID if already set', async () => { const fileSource = Buffer.from('test file content'); const converter = new SuperConverter({ docx: mockDocx, fileSource, }); - // Generate hash first (async) + // getDocumentIdentifier assigns a GUID await converter.getDocumentIdentifier(); + expect(converter.getDocumentGuid()).toBe('test-uuid-1234'); - // Now check if has temporary ID - expect(hasTemporaryId(converter)).toBe(true); + // Clear the mock to verify promoteToGuid doesn't generate a new one + vi.clearAllMocks(); - // Promote to GUID + // promoteToGuid should return the existing GUID const guid = converter.promoteToGuid(); expect(guid).toBe('test-uuid-1234'); - expect(converter.getDocumentGuid()).toBe('test-uuid-1234'); - expect(hasTemporaryId(converter)).toBe(false); - expect(converter.documentModified).toBe(true); + expect(uuidv4).not.toHaveBeenCalled(); }); it('does not re-promote if already has GUID', () => { From 73c33b9a63f8c87d5e92aa5275ff162975a99112 Mon Sep 17 00:00:00 2001 From: aorlov Date: Thu, 5 Feb 2026 17:21:25 +0100 Subject: [PATCH 06/11] refactor: simplify telemetry configuration and improve document tracking - Renamed `isNewFile` to `isBlankDoc` in SuperConverter for clarity regarding document creation context. - Updated related comments and documentation to reflect the new naming and functionality. - Enhanced test cases to align with the refactored telemetry and document handling logic. --- packages/super-editor/src/core/Editor.ts | 10 +-- .../core/super-converter/SuperConverter.js | 14 ++-- shared/common/Telemetry.test.ts | 76 ++++--------------- shared/common/Telemetry.ts | 36 ++------- 4 files changed, 30 insertions(+), 106 deletions(-) diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index 05a3dfd705..e7d4913235 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -502,12 +502,10 @@ export class Editor extends EventEmitter { try { this.#telemetry = new Telemetry({ - config: { - enabled: true, - endpoint: telemetryConfig.endpoint, - licenseKey, - metadata: telemetryConfig.metadata, - }, + enabled: true, + endpoint: telemetryConfig.endpoint, + licenseKey, + metadata: telemetryConfig.metadata, }); } catch { // Fail silently - telemetry should never break the app diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.js b/packages/super-editor/src/core/super-converter/SuperConverter.js index 831093ed64..1863224101 100644 --- a/packages/super-editor/src/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/core/super-converter/SuperConverter.js @@ -245,8 +245,8 @@ class SuperConverter { this.documentUniqueIdentifier = null; // Final identifier (identifierHash or contentHash) this.documentModified = false; // Track if document has been edited - // Track if this is a new file created from blank template - this.isNewFile = params?.isNewFile || false; + // Track if this is a blank document created from template + this.isBlankDoc = params?.isNewFile || false; // Parse the initial XML, if provided if (this.docx.length || this.xml) this.parseFromXml(); @@ -725,8 +725,8 @@ class SuperConverter { } } - // NEW FILE: set fresh timestamp (ensures unique identifier for each new doc from template) - if (this.isNewFile) { + // BLANK DOC: set fresh timestamp (ensures unique identifier for each new doc from template) + if (this.isBlankDoc) { this.setDocumentCreatedTimestamp(SuperConverter.generateWordTimestamp()); console.debug('[super-converter] New file: set fresh timestamp', { documentGuid: this.documentGuid, @@ -795,11 +795,11 @@ class SuperConverter { /** * Get document unique identifier (async) * - * For new files (isNewFile: true): + * For blank documents (isBlankDoc: true): * - GUID and timestamp already set in resolveDocumentGuid() * - Returns identifierHash(guid|timestamp) * - * For imported files (isNewFile: false): + * For imported files (isBlankDoc: false): * - If both documentGuid and dcterms:created exist: returns identifierHash * - Otherwise: returns contentHash and generates missing metadata for future exports * @@ -822,7 +822,7 @@ class SuperConverter { documentUniqueIdentifier: this.documentUniqueIdentifier, documentGuid: this.documentGuid, createdAt: this.getDocumentCreatedTimestamp(), - isNewFile: this.isNewFile, + isBlankDoc: this.isBlankDoc, }); } else { // Missing one or both: use contentHash for stability (same file = same hash) diff --git a/shared/common/Telemetry.test.ts b/shared/common/Telemetry.test.ts index bde69285f3..8786574c75 100644 --- a/shared/common/Telemetry.test.ts +++ b/shared/common/Telemetry.test.ts @@ -19,18 +19,7 @@ describe('Telemetry', () => { describe('telemetry disabled', () => { it('does not send telemetry when disabled', async () => { - const telemetry = new Telemetry({ - config: { ...testConfig, enabled: false }, - }); - telemetry.trackDocumentOpen('doc-123'); - - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(fetchSpy).not.toHaveBeenCalled(); - }); - - it('does not send telemetry after being disabled at runtime', async () => { - const telemetry = new Telemetry({ config: testConfig }); - telemetry.disable(); + const telemetry = new Telemetry({ ...testConfig, enabled: false }); telemetry.trackDocumentOpen('doc-123'); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -40,7 +29,7 @@ describe('Telemetry', () => { describe('telemetry enabled', () => { it('sends telemetry when enabled', async () => { - const telemetry = new Telemetry({ config: testConfig }); + const telemetry = new Telemetry(testConfig); telemetry.trackDocumentOpen('doc-123', '2024-01-15T10:30:00Z'); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -67,24 +56,11 @@ describe('Telemetry', () => { expect(payload.events[0].documentCreatedAt).toBe('2024-01-15T10:30:00Z'); expect(payload.events[0].timestamp).toBeDefined(); }); - - it('sends telemetry after being enabled at runtime', async () => { - const telemetry = new Telemetry({ - config: { ...testConfig, enabled: false }, - }); - telemetry.enable(); - telemetry.trackDocumentOpen('doc-123'); - - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(fetchSpy).toHaveBeenCalledTimes(1); - }); }); describe('telemetry without license key', () => { it('sends telemetry when enabled without license key', async () => { - const telemetry = new Telemetry({ - config: { enabled: true, endpoint: 'https://test.example.com/collect' }, - }); + const telemetry = new Telemetry({ enabled: true, endpoint: 'https://test.example.com/collect' }); telemetry.trackDocumentOpen('doc-123'); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -105,7 +81,7 @@ describe('Telemetry', () => { describe('telemetry without document id', () => { it('sends telemetry with null documentId', async () => { - const telemetry = new Telemetry({ config: testConfig }); + const telemetry = new Telemetry(testConfig); telemetry.trackDocumentOpen(null, null); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -118,7 +94,7 @@ describe('Telemetry', () => { }); it('sends telemetry with documentId but no documentCreatedAt', async () => { - const telemetry = new Telemetry({ config: testConfig }); + const telemetry = new Telemetry(testConfig); telemetry.trackDocumentOpen('doc-456'); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -134,9 +110,7 @@ describe('Telemetry', () => { describe('telemetry with custom endpoint', () => { it('sends telemetry to custom endpoint when provided', async () => { const customEndpoint = 'https://custom.telemetry.com/v1/events'; - const telemetry = new Telemetry({ - config: { enabled: true, endpoint: customEndpoint }, - }); + const telemetry = new Telemetry({ enabled: true, endpoint: customEndpoint }); telemetry.trackDocumentOpen('doc-123'); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -146,9 +120,7 @@ describe('Telemetry', () => { }); it('sends telemetry to default endpoint when not provided', async () => { - const telemetry = new Telemetry({ - config: { enabled: true }, - }); + const telemetry = new Telemetry({ enabled: true }); telemetry.trackDocumentOpen('doc-123'); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -172,7 +144,7 @@ describe('Telemetry', () => { nested: { key: 'value' }, }, }; - const telemetry = new Telemetry({ config: configWithMetadata }); + const telemetry = new Telemetry(configWithMetadata); telemetry.trackDocumentOpen('doc-123'); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -187,7 +159,7 @@ describe('Telemetry', () => { }); it('omits metadata from payload when not provided', async () => { - const telemetry = new Telemetry({ config: testConfig }); + const telemetry = new Telemetry(testConfig); telemetry.trackDocumentOpen('doc-123'); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -199,9 +171,7 @@ describe('Telemetry', () => { }); it('includes empty metadata object when provided as empty', async () => { - const telemetry = new Telemetry({ - config: { ...testConfig, metadata: {} }, - }); + const telemetry = new Telemetry({ ...testConfig, metadata: {} }); telemetry.trackDocumentOpen('doc-123'); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -215,7 +185,7 @@ describe('Telemetry', () => { describe('payload structure', () => { it('includes browser info at root level', async () => { - const telemetry = new Telemetry({ config: testConfig }); + const telemetry = new Telemetry(testConfig); telemetry.trackDocumentOpen('doc-123'); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -229,7 +199,7 @@ describe('Telemetry', () => { }); it('includes superdocVersion at root level', async () => { - const telemetry = new Telemetry({ config: testConfig }); + const telemetry = new Telemetry(testConfig); telemetry.trackDocumentOpen('doc-123'); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -241,31 +211,11 @@ describe('Telemetry', () => { }); }); - describe('enable/disable', () => { - it('can disable telemetry', () => { - const telemetry = new Telemetry({ config: testConfig }); - expect(telemetry.isEnabled()).toBe(true); - - telemetry.disable(); - expect(telemetry.isEnabled()).toBe(false); - }); - - it('can enable telemetry', () => { - const telemetry = new Telemetry({ - config: { ...testConfig, enabled: false }, - }); - expect(telemetry.isEnabled()).toBe(false); - - telemetry.enable(); - expect(telemetry.isEnabled()).toBe(true); - }); - }); - describe('error handling', () => { it('fails silently on fetch error', async () => { fetchSpy.mockRejectedValue(new Error('Network error')); - const telemetry = new Telemetry({ config: testConfig }); + const telemetry = new Telemetry(testConfig); // Should not throw expect(() => telemetry.trackDocumentOpen('doc-123')).not.toThrow(); diff --git a/shared/common/Telemetry.ts b/shared/common/Telemetry.ts index c391e194f1..4fac85b0e9 100644 --- a/shared/common/Telemetry.ts +++ b/shared/common/Telemetry.ts @@ -38,11 +38,8 @@ export interface TelemetryPayload { events: DocumentOpenEvent[]; } -interface TelemetryOptions { - config: TelemetryConfig; -} - const DEFAULT_ENDPOINT = 'https://livetest-3---superdoc-telemetry-4yffz5xqqq-uc.a.run.app/v1/collect'; +// const DEFAULT_ENDPOINT = 'http://localhost:3051/v1/collect'; function getSuperdocVersion(): string { try { @@ -59,11 +56,11 @@ export class Telemetry { private licenseKey: string; private metadata?: Record; - constructor(options: TelemetryOptions) { - this.enabled = options.config.enabled; - this.endpoint = options.config.endpoint || DEFAULT_ENDPOINT; - this.licenseKey = options.config.licenseKey || ''; - this.metadata = options.config.metadata; + constructor(config: TelemetryConfig) { + this.enabled = config.enabled; + this.endpoint = config.endpoint || DEFAULT_ENDPOINT; + this.licenseKey = config.licenseKey || ''; + this.metadata = config.metadata; this.superdocVersion = getSuperdocVersion(); } @@ -132,25 +129,4 @@ export class Telemetry { console.error('[Telemetry] Fetch error:', error); } } - - /** - * Disable telemetry - */ - disable(): void { - this.enabled = false; - } - - /** - * Enable telemetry - */ - enable(): void { - this.enabled = true; - } - - /** - * Check if telemetry is enabled - */ - isEnabled(): boolean { - return this.enabled; - } } From 676b09114b4c99b42ce418346394e5ed6f1a18a2 Mon Sep 17 00:00:00 2001 From: aorlov Date: Thu, 5 Feb 2026 18:29:35 +0100 Subject: [PATCH 07/11] refactor: streamline document tracking and metadata generation - Ensured document metadata is generated regardless of telemetry settings --- packages/super-editor/src/core/Editor.ts | 53 ++++++++++++------------ 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index c72efec925..aba9bd0a28 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -480,16 +480,14 @@ export class Editor extends EventEmitter { return this.options.document ?? this.options.mockDocument ?? (canUseDOM() ? document : null); } - async #emitCreateAsync(): Promise { - // Ensure document metadata (GUID, timestamp) is generated on import - // This must happen regardless of telemetry being enabled - await this.getDocumentIdentifier(); - this.#trackDocumentOpen(); - + #emitCreateAsync(): void { setTimeout(() => { if (this.isDestroyed) return; this.emit('create', { editor: this }); }, 0); + + // Generate metadata and track telemetry (non-blocking) + this.#trackDocumentOpen(); } /** @@ -516,24 +514,27 @@ export class Editor extends EventEmitter { } /** - * Track document open event for telemetry + * Ensure document metadata is generated and track telemetry if enabled */ - async #trackDocumentOpen(): Promise { - if (!this.#telemetry || this.#documentOpenTracked) return; + #trackDocumentOpen(): void { + // Always generate metadata (GUID, timestamp) regardless of telemetry + this.getDocumentIdentifier().then((documentId) => { + // Only track if telemetry enabled and not already tracked + if (!this.#telemetry || this.#documentOpenTracked) return; - try { - const documentId = await this.getDocumentIdentifier(); - const documentCreatedAt = this.converter?.getDocumentCreatedTimestamp?.() || null; - console.debug('[super-editor] Document info:', { - documentId, - documentCreatedAt, - isNewFile: this.options.isNewFile, - }); - this.#telemetry.trackDocumentOpen(documentId, documentCreatedAt); - this.#documentOpenTracked = true; - } catch { - // Fail silently - telemetry should never break the app - } + try { + const documentCreatedAt = this.converter?.getDocumentCreatedTimestamp?.() || null; + console.debug('[super-editor] Document info:', { + documentId, + documentCreatedAt, + isNewFile: this.options.isNewFile, + }); + this.#telemetry.trackDocumentOpen(documentId, documentCreatedAt); + this.#documentOpenTracked = true; + } catch { + // Fail silently - telemetry should never break the app + } + }); } /** @@ -1065,16 +1066,16 @@ export class Editor extends EventEmitter { } } - async mount(el: HTMLElement | null): Promise { + mount(el: HTMLElement | null): void { this.#createView(el); - await this.getDocumentIdentifier(); - setTimeout(() => { if (this.isDestroyed) return; this.emit('create', { editor: this }); - this.#trackDocumentOpen(); }, 0); + + // Generate metadata and track telemetry (non-blocking) + this.#trackDocumentOpen(); } unmount(): void { From ad16d294f508e2b55b282dc68640af6242d10064 Mon Sep 17 00:00:00 2001 From: aorlov Date: Thu, 5 Feb 2026 21:39:49 +0100 Subject: [PATCH 08/11] refactor: enhance telemetry integration and license key handling - Simplified license key management in telemetry initialization. - Updated tests to reflect changes in license key usage and ensure correct behavior. - Introduced COMMUNITY_LICENSE_KEY constant for consistent license key usage across modules. --- .../src/core/Editor.telemetry.test.ts | 104 +++++++----------- packages/super-editor/src/core/Editor.ts | 17 +-- packages/superdoc/src/core/SuperDoc.js | 6 +- shared/common/Telemetry.ts | 5 + shared/common/index.ts | 2 +- 5 files changed, 51 insertions(+), 83 deletions(-) diff --git a/packages/super-editor/src/core/Editor.telemetry.test.ts b/packages/super-editor/src/core/Editor.telemetry.test.ts index 014753fc80..9a9fcc2e7d 100644 --- a/packages/super-editor/src/core/Editor.telemetry.test.ts +++ b/packages/super-editor/src/core/Editor.telemetry.test.ts @@ -1,15 +1,12 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { Telemetry } from '@superdoc/common'; -import { Editor } from './Editor.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Telemetry, COMMUNITY_LICENSE_KEY } from '@superdoc/common'; // Mock the Telemetry class to verify it's called correctly vi.mock('@superdoc/common', () => ({ Telemetry: vi.fn().mockImplementation(() => ({ trackDocumentOpen: vi.fn(), - isEnabled: vi.fn().mockReturnValue(true), - disable: vi.fn(), - enable: vi.fn(), })), + COMMUNITY_LICENSE_KEY: 'community-and-eval-agplv3', })); // Test the telemetry initialization logic in isolation @@ -18,9 +15,7 @@ function initTelemetry(options: { telemetry?: { enabled: boolean; endpoint?: string; metadata?: Record } | null; licenseKey?: string; }): Telemetry | null { - // Use default license key if not provided (mirrors Editor.options default) - const licenseKey = options.licenseKey ?? Editor.COMMUNITY_LICENSE_KEY; - const { telemetry: telemetryConfig } = options; + const { telemetry: telemetryConfig, licenseKey } = options; // Skip if telemetry is not enabled if (!telemetryConfig?.enabled) { @@ -29,12 +24,10 @@ function initTelemetry(options: { try { return new Telemetry({ - config: { - enabled: true, - endpoint: telemetryConfig.endpoint, - licenseKey, - metadata: telemetryConfig.metadata, - }, + enabled: true, + endpoint: telemetryConfig.endpoint, + licenseKey: licenseKey === undefined ? COMMUNITY_LICENSE_KEY : licenseKey, + metadata: telemetryConfig.metadata, }); } catch { // Fail silently - telemetry should never break the app @@ -88,18 +81,16 @@ describe('Editor Telemetry Integration', () => { expect(result).not.toBeNull(); expect(Telemetry).toHaveBeenCalledTimes(1); expect(Telemetry).toHaveBeenCalledWith({ - config: { - enabled: true, - endpoint: undefined, - licenseKey: 'test-key', - metadata: undefined, - }, + enabled: true, + endpoint: undefined, + licenseKey: 'test-key', + metadata: undefined, }); }); }); - describe('default license key', () => { - it('uses default community license key when licenseKey is not provided', () => { + describe('license key handling', () => { + it('uses COMMUNITY_LICENSE_KEY when licenseKey not provided', () => { const result = initTelemetry({ telemetry: { enabled: true }, }); @@ -107,20 +98,14 @@ describe('Editor Telemetry Integration', () => { expect(result).not.toBeNull(); expect(Telemetry).toHaveBeenCalledTimes(1); expect(Telemetry).toHaveBeenCalledWith({ - config: { - enabled: true, - endpoint: undefined, - licenseKey: Editor.COMMUNITY_LICENSE_KEY, - metadata: undefined, - }, + enabled: true, + endpoint: undefined, + licenseKey: 'community-and-eval-agplv3', + metadata: undefined, }); }); - it('uses default community license key value "community-and-eval-agplv3"', () => { - expect(Editor.COMMUNITY_LICENSE_KEY).toBe('community-and-eval-agplv3'); - }); - - it('overrides default license key when custom key is provided', () => { + it('passes custom license key when provided', () => { const customKey = 'my-custom-license-key'; const result = initTelemetry({ telemetry: { enabled: true }, @@ -129,12 +114,10 @@ describe('Editor Telemetry Integration', () => { expect(result).not.toBeNull(); expect(Telemetry).toHaveBeenCalledWith({ - config: { - enabled: true, - endpoint: undefined, - licenseKey: customKey, - metadata: undefined, - }, + enabled: true, + endpoint: undefined, + licenseKey: customKey, + metadata: undefined, }); }); }); @@ -149,12 +132,10 @@ describe('Editor Telemetry Integration', () => { expect(result).not.toBeNull(); expect(Telemetry).toHaveBeenCalledWith({ - config: { - enabled: true, - endpoint: customEndpoint, - licenseKey: 'test-key', - metadata: undefined, - }, + enabled: true, + endpoint: customEndpoint, + licenseKey: 'test-key', + metadata: undefined, }); }); }); @@ -172,12 +153,10 @@ describe('Editor Telemetry Integration', () => { expect(result).not.toBeNull(); expect(Telemetry).toHaveBeenCalledWith({ - config: { - enabled: true, - endpoint: undefined, - licenseKey: 'test-key', - metadata, - }, + enabled: true, + endpoint: undefined, + licenseKey: 'test-key', + metadata, }); }); @@ -188,16 +167,15 @@ describe('Editor Telemetry Integration', () => { }; const result = initTelemetry({ telemetry: { enabled: true, metadata }, + licenseKey: 'test-key', }); expect(result).not.toBeNull(); expect(Telemetry).toHaveBeenCalledWith({ - config: { - enabled: true, - endpoint: undefined, - licenseKey: Editor.COMMUNITY_LICENSE_KEY, - metadata, - }, + enabled: true, + endpoint: undefined, + licenseKey: 'test-key', + metadata, }); }); }); @@ -217,12 +195,10 @@ describe('Editor Telemetry Integration', () => { expect(result).not.toBeNull(); expect(Telemetry).toHaveBeenCalledWith({ - config: { - enabled: true, - endpoint: 'https://custom.endpoint.com/collect', - licenseKey: 'license-key-123', - metadata: { customerId: 'abc', env: 'production' }, - }, + enabled: true, + endpoint: 'https://custom.endpoint.com/collect', + licenseKey: 'license-key-123', + metadata: { customerId: 'abc', env: 'production' }, }); }); }); diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index aba9bd0a28..91e2776d7a 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -54,7 +54,7 @@ import type { EditorRenderer } from './renderers/EditorRenderer.js'; import { ProseMirrorRenderer } from './renderers/ProseMirrorRenderer.js'; import { BLANK_DOCX_DATA_URI } from './blank-docx.js'; import { getArrayBufferFromUrl } from '@core/super-converter/helpers.js'; -import { Telemetry } from '@superdoc/common'; +import { Telemetry, COMMUNITY_LICENSE_KEY } from '@superdoc/common'; declare const __APP_VERSION__: string; declare const version: string | undefined; @@ -151,23 +151,10 @@ export interface SaveOptions { */ export type ExportOptions = SaveOptions; -/** - * Community license key for AGPLv3 / evaluation usage. - * This is the default license key - */ -const COMMUNITY_LICENSE_KEY = 'community-and-eval-agplv3'; - /** * Main editor class that manages document state, extensions, and user interactions */ export class Editor extends EventEmitter { - /** - * Community license key for AGPLv3 / evaluation usage. - * This is the default license key - you don't need to set it explicitly - * unless you have a commercial license to override it. - */ - static readonly COMMUNITY_LICENSE_KEY = COMMUNITY_LICENSE_KEY; - /** * Command service for handling editor commands */ @@ -505,7 +492,7 @@ export class Editor extends EventEmitter { this.#telemetry = new Telemetry({ enabled: true, endpoint: telemetryConfig.endpoint, - licenseKey, + licenseKey: licenseKey === undefined ? COMMUNITY_LICENSE_KEY : licenseKey, metadata: telemetryConfig.metadata, }); } catch { diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index 9ed3ec6246..d0f8618285 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -4,7 +4,7 @@ import { EventEmitter } from 'eventemitter3'; import { v4 as uuidv4 } from 'uuid'; import { HocuspocusProviderWebsocket } from '@hocuspocus/provider'; -import { DOCX, PDF, HTML } from '@superdoc/common'; +import { DOCX, PDF, HTML, COMMUNITY_LICENSE_KEY } from '@superdoc/common'; import { SuperToolbar, createZip } from '@superdoc/super-editor'; import { SuperComments } from '../components/CommentsLayer/commentsList/super-comments-list.js'; import { createSuperdocVueApp } from './create-app.js'; @@ -72,8 +72,8 @@ export class SuperDoc extends EventEmitter { modules: {}, // Optional: Modules to load. Use modules.ai.{your_key} to pass in your key permissionResolver: null, // Optional: Override for permission checks - // License key for organization identification - licenseKey: null, + // License key for organization identification (defaults to community key) + licenseKey: COMMUNITY_LICENSE_KEY, // Telemetry settings telemetry: { enabled: false }, // Enable to track document opens diff --git a/shared/common/Telemetry.ts b/shared/common/Telemetry.ts index 4fac85b0e9..4b8e431acd 100644 --- a/shared/common/Telemetry.ts +++ b/shared/common/Telemetry.ts @@ -41,6 +41,11 @@ export interface TelemetryPayload { const DEFAULT_ENDPOINT = 'https://livetest-3---superdoc-telemetry-4yffz5xqqq-uc.a.run.app/v1/collect'; // const DEFAULT_ENDPOINT = 'http://localhost:3051/v1/collect'; +/** + * Community license key for AGPLv3 / evaluation usage. + */ +export const COMMUNITY_LICENSE_KEY = 'community-and-eval-agplv3'; + function getSuperdocVersion(): string { try { return typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'unknown'; diff --git a/shared/common/index.ts b/shared/common/index.ts index e95cf1815a..690f4a320c 100644 --- a/shared/common/index.ts +++ b/shared/common/index.ts @@ -34,5 +34,5 @@ export { default as vClickOutside } from './helpers/v-click-outside'; export * from './collaboration/awareness'; // Telemetry -export { Telemetry } from './Telemetry'; +export { Telemetry, COMMUNITY_LICENSE_KEY } from './Telemetry'; export type { TelemetryConfig, TelemetryPayload, DocumentOpenEvent, BrowserInfo } from './Telemetry'; From 336856fa0578065b39d2b15051e239bc5a68ee4f Mon Sep 17 00:00:00 2001 From: aorlov Date: Thu, 5 Feb 2026 21:43:50 +0100 Subject: [PATCH 09/11] refactor: update telemetry configuration and constructor parameters --- shared/common/Telemetry.d.ts | 26 ++++++-------------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/shared/common/Telemetry.d.ts b/shared/common/Telemetry.d.ts index 419cafb61e..7d76cf5db5 100644 --- a/shared/common/Telemetry.d.ts +++ b/shared/common/Telemetry.d.ts @@ -9,7 +9,7 @@ export interface TelemetryConfig { enabled: boolean; endpoint?: string; - licenseKey?: string; + licenseKey?: string | null; metadata?: Record; } @@ -36,12 +36,13 @@ export interface TelemetryPayload { events: DocumentOpenEvent[]; } -interface TelemetryOptions { - config: TelemetryConfig; -} +/** + * Community license key for AGPLv3 / evaluation usage. + */ +export declare const COMMUNITY_LICENSE_KEY: 'community-and-eval-agplv3'; export declare class Telemetry { - constructor(options: TelemetryOptions); + constructor(config: TelemetryConfig); /** * Track a document open event - sends immediately @@ -49,19 +50,4 @@ export declare class Telemetry { * @param documentCreatedAt - Document creation timestamp (dcterms:created), or null if unavailable */ trackDocumentOpen(documentId: string | null, documentCreatedAt?: string | null): void; - - /** - * Disable telemetry - */ - disable(): void; - - /** - * Enable telemetry - */ - enable(): void; - - /** - * Check if telemetry is enabled - */ - isEnabled(): boolean; } From 0c176b97a6c6e3f552c7163c17c8843cee77fe65 Mon Sep 17 00:00:00 2001 From: aorlov Date: Fri, 6 Feb 2026 22:55:28 +0100 Subject: [PATCH 10/11] refactor: enhance telemetry logging and update endpoint --- packages/super-editor/src/core/Editor.ts | 7 ++----- .../src/core/super-converter/SuperConverter.js | 16 ---------------- shared/common/Telemetry.test.ts | 3 +-- shared/common/Telemetry.ts | 11 ++++------- 4 files changed, 7 insertions(+), 30 deletions(-) diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index 91e2776d7a..89e2e80e67 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -485,6 +485,7 @@ export class Editor extends EventEmitter { // Skip if telemetry is not enabled if (!telemetryConfig?.enabled) { + console.debug('[super-editor] Telemetry: disabled'); return; } @@ -495,6 +496,7 @@ export class Editor extends EventEmitter { licenseKey: licenseKey === undefined ? COMMUNITY_LICENSE_KEY : licenseKey, metadata: telemetryConfig.metadata, }); + console.debug('[super-editor] Telemetry: enabled'); } catch { // Fail silently - telemetry should never break the app } @@ -511,11 +513,6 @@ export class Editor extends EventEmitter { try { const documentCreatedAt = this.converter?.getDocumentCreatedTimestamp?.() || null; - console.debug('[super-editor] Document info:', { - documentId, - documentCreatedAt, - isNewFile: this.options.isNewFile, - }); this.#telemetry.trackDocumentOpen(documentId, documentCreatedAt); this.#documentOpenTracked = true; } catch { diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.js b/packages/super-editor/src/core/super-converter/SuperConverter.js index 587e02ad17..57b4f6b4db 100644 --- a/packages/super-editor/src/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/core/super-converter/SuperConverter.js @@ -736,10 +736,6 @@ class SuperConverter { // BLANK DOC: set fresh timestamp (ensures unique identifier for each new doc from template) if (this.isBlankDoc) { this.setDocumentCreatedTimestamp(SuperConverter.generateWordTimestamp()); - console.debug('[super-converter] New file: set fresh timestamp', { - documentGuid: this.documentGuid, - createdAt: this.getDocumentCreatedTimestamp(), - }); } } @@ -826,12 +822,6 @@ class SuperConverter { if (hasGuid && hasTimestamp) { // Both exist: use identifierHash this.documentUniqueIdentifier = this.#generateIdentifierHash(); - console.debug('[super-converter] Document identifier (metadata hash):', { - documentUniqueIdentifier: this.documentUniqueIdentifier, - documentGuid: this.documentGuid, - createdAt: this.getDocumentCreatedTimestamp(), - isBlankDoc: this.isBlankDoc, - }); } 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 @@ -843,12 +833,6 @@ class SuperConverter { } this.documentModified = true; // Ensures metadata is saved on export this.documentUniqueIdentifier = await this.#generateContentHash(); - console.debug('[super-converter] Document identifier (content hash):', { - documentUniqueIdentifier: this.documentUniqueIdentifier, - documentGuid: this.documentGuid, - createdAt: this.getDocumentCreatedTimestamp(), - reason: !hasGuid && !hasTimestamp ? 'missing both' : !hasGuid ? 'missing GUID' : 'missing timestamp', - }); } return this.documentUniqueIdentifier; diff --git a/shared/common/Telemetry.test.ts b/shared/common/Telemetry.test.ts index 8786574c75..11efb4d897 100644 --- a/shared/common/Telemetry.test.ts +++ b/shared/common/Telemetry.test.ts @@ -126,9 +126,8 @@ describe('Telemetry', () => { await new Promise((resolve) => setTimeout(resolve, 0)); expect(fetchSpy).toHaveBeenCalledTimes(1); - // Default endpoint is https://livetest-3---superdoc-telemetry-4yffz5xqqq-uc.a.run.app/v1/collect expect(fetchSpy).toHaveBeenCalledWith( - 'https://livetest-3---superdoc-telemetry-4yffz5xqqq-uc.a.run.app/v1/collect', + 'https://ingest.superdoc.dev/v1/collect', expect.objectContaining({ method: 'POST' }), ); }); diff --git a/shared/common/Telemetry.ts b/shared/common/Telemetry.ts index 4b8e431acd..25dec3c652 100644 --- a/shared/common/Telemetry.ts +++ b/shared/common/Telemetry.ts @@ -38,8 +38,7 @@ export interface TelemetryPayload { events: DocumentOpenEvent[]; } -const DEFAULT_ENDPOINT = 'https://livetest-3---superdoc-telemetry-4yffz5xqqq-uc.a.run.app/v1/collect'; -// const DEFAULT_ENDPOINT = 'http://localhost:3051/v1/collect'; +const DEFAULT_ENDPOINT = 'https://ingest.superdoc.dev/v1/collect'; /** * Community license key for AGPLv3 / evaluation usage. @@ -118,9 +117,8 @@ export class Telemetry { events: [event], }; - console.log('[Telemetry] Sending payload:', payload); try { - const response = await fetch(this.endpoint, { + await fetch(this.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -129,9 +127,8 @@ export class Telemetry { body: JSON.stringify(payload), credentials: 'omit', }); - console.log('[Telemetry] Response status:', response.status); - } catch (error) { - console.error('[Telemetry] Fetch error:', error); + } catch { + // Fail silently - telemetry should never break the app } } } From 79be1a348aecf2cfe1a052d8e90319221e9fbf74 Mon Sep 17 00:00:00 2001 From: aorlov Date: Fri, 6 Feb 2026 22:58:08 +0100 Subject: [PATCH 11/11] fix: disable telemetry by default --- packages/superdoc/src/dev/components/SuperdocDev.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/superdoc/src/dev/components/SuperdocDev.vue b/packages/superdoc/src/dev/components/SuperdocDev.vue index ec18b78ffc..0d202d1734 100644 --- a/packages/superdoc/src/dev/components/SuperdocDev.vue +++ b/packages/superdoc/src/dev/components/SuperdocDev.vue @@ -180,7 +180,7 @@ const init = async () => { documentMode: 'editing', licenseKey: 'community-and-eval-agplv3', telemetry: { - enabled: true + enabled: false, }, comments: { visible: true,