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..9a9fcc2e7d --- /dev/null +++ b/packages/super-editor/src/core/Editor.telemetry.test.ts @@ -0,0 +1,205 @@ +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(), + })), + COMMUNITY_LICENSE_KEY: 'community-and-eval-agplv3', +})); + +// 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; +}): Telemetry | null { + const { telemetry: telemetryConfig, licenseKey } = options; + + // Skip if telemetry is not enabled + if (!telemetryConfig?.enabled) { + return null; + } + + try { + return new Telemetry({ + enabled: true, + endpoint: telemetryConfig.endpoint, + licenseKey: licenseKey === undefined ? COMMUNITY_LICENSE_KEY : licenseKey, + 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({ + enabled: true, + endpoint: undefined, + licenseKey: 'test-key', + metadata: undefined, + }); + }); + }); + + describe('license key handling', () => { + it('uses COMMUNITY_LICENSE_KEY when licenseKey not provided', () => { + const result = initTelemetry({ + telemetry: { enabled: true }, + }); + + expect(result).not.toBeNull(); + expect(Telemetry).toHaveBeenCalledTimes(1); + expect(Telemetry).toHaveBeenCalledWith({ + enabled: true, + endpoint: undefined, + licenseKey: 'community-and-eval-agplv3', + metadata: undefined, + }); + }); + + it('passes custom license key when provided', () => { + const customKey = 'my-custom-license-key'; + const result = initTelemetry({ + telemetry: { enabled: true }, + licenseKey: customKey, + }); + + expect(result).not.toBeNull(); + expect(Telemetry).toHaveBeenCalledWith({ + enabled: true, + endpoint: undefined, + licenseKey: customKey, + 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({ + 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({ + 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 }, + licenseKey: 'test-key', + }); + + expect(result).not.toBeNull(); + expect(Telemetry).toHaveBeenCalledWith({ + enabled: true, + endpoint: undefined, + licenseKey: 'test-key', + 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({ + 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 a98073761a..89e2e80e67 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, COMMUNITY_LICENSE_KEY } from '@superdoc/common'; declare const __APP_VERSION__: string; declare const version: string | undefined; @@ -243,6 +244,16 @@ export class Editor extends EventEmitter { */ setHighContrastMode?: (enabled: boolean) => void; + /** + * Telemetry instance for tracking document opens + */ + #telemetry: Telemetry | null = null; + + /** + * Guard flag to prevent double-tracking document open + */ + #documentOpenTracked = false; + options: EditorOptions = { element: null, selector: null, @@ -327,6 +338,12 @@ export class Editor extends EventEmitter { // header/footer editors may have parent(main) editor set parentEditor: null, + + // License key (defaults to community license) + licenseKey: COMMUNITY_LICENSE_KEY, + + // Telemetry configuration + telemetry: null, }; /** @@ -393,6 +410,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; @@ -454,6 +472,53 @@ export class Editor extends EventEmitter { if (this.isDestroyed) return; this.emit('create', { editor: this }); }, 0); + + // Generate metadata and track telemetry (non-blocking) + this.#trackDocumentOpen(); + } + + /** + * Initialize telemetry if configured + */ + #initTelemetry(): void { + const { telemetry: telemetryConfig, licenseKey } = this.options; + + // Skip if telemetry is not enabled + if (!telemetryConfig?.enabled) { + console.debug('[super-editor] Telemetry: disabled'); + return; + } + + try { + this.#telemetry = new Telemetry({ + enabled: true, + endpoint: telemetryConfig.endpoint, + 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 + } + } + + /** + * Ensure document metadata is generated and track telemetry if enabled + */ + #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 documentCreatedAt = this.converter?.getDocumentCreatedTimestamp?.() || null; + this.#telemetry.trackDocumentOpen(documentId, documentCreatedAt); + this.#documentOpenTracked = true; + } catch { + // Fail silently - telemetry should never break the app + } + }); } /** @@ -992,6 +1057,9 @@ export class Editor extends EventEmitter { if (this.isDestroyed) return; this.emit('create', { editor: this }); }, 0); + + // Generate metadata and track telemetry (non-blocking) + this.#trackDocumentOpen(); } unmount(): void { @@ -1553,6 +1621,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, }); } } @@ -2118,7 +2187,8 @@ export class Editor extends EventEmitter { } /** - * Get document identifier (async - may generate hash) + * Get document unique identifier (async) + * Returns a stable identifier for the document (identifierHash or contentHash) */ async getDocumentIdentifier(): Promise { return (await this.converter?.getDocumentIdentifier()) || null; @@ -2521,6 +2591,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), @@ -2531,6 +2606,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 64752213fa..57b4f6b4db 100644 --- a/packages/super-editor/src/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/core/super-converter/SuperConverter.js @@ -241,10 +241,13 @@ 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 blank document created from template + this.isBlankDoc = params?.isNewFile || false; + // Parse the initial XML, if provided if (this.docx.length || this.xml) this.parseFromXml(); } @@ -596,6 +599,72 @@ 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 + */ + 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) return; + + // 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); + } + } + /** * Get document GUID from docx files (static method) * @static @@ -648,21 +717,26 @@ class SuperConverter { /** * Resolve existing document GUID (synchronous) + * For new files: reads existing GUID and sets fresh timestamp + * For imported files: reads existing GUIDs only */ resolveDocumentGuid() { // 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; + // BLANK DOC: set fresh timestamp (ensures unique identifier for each new doc from template) + if (this.isBlankDoc) { + this.setDocumentCreatedTimestamp(SuperConverter.generateWordTimestamp()); } - // Don't generate hash here - do it lazily when needed } /** @@ -677,10 +751,28 @@ class SuperConverter { } /** - * Generate document hash (async, lazy) + * 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" */ - async #generateDocumentHash() { - if (!this.fileSource) return `HASH-${Date.now()}`; + #generateIdentifierHash() { + const combined = `${this.documentGuid}|${this.getDocumentCreatedTimestamp()}`; + const buffer = Buffer.from(combined, 'utf8'); + const hash = crc32(buffer); + return `HASH-${hash.toString('hex').toUpperCase()}`; + } + + /** + * 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; @@ -693,41 +785,68 @@ class SuperConverter { const arrayBuffer = await this.fileSource.arrayBuffer(); buffer = Buffer.from(arrayBuffer); } else { - return `HASH-${Date.now()}`; + return `HASH-${uuidv4().replace(/-/g, '').substring(0, 8).toUpperCase()}`; } const hash = crc32(buffer); return `HASH-${hash.toString('hex').toUpperCase()}`; } catch (e) { - console.warn('Could not generate document hash:', e); - return `HASH-${Date.now()}`; + console.warn('[super-converter] Could not generate content hash:', e); + return `HASH-${uuidv4().replace(/-/g, '').substring(0, 8).toUpperCase()}`; } } /** - * Get document identifier (GUID or hash) - async for lazy hash generation + * Get document unique identifier (async) + * + * For blank documents (isBlankDoc: true): + * - GUID and timestamp already set in resolveDocumentGuid() + * - Returns identifierHash(guid|timestamp) + * + * For imported files (isBlankDoc: false): + * - If both documentGuid and dcterms:created exist: returns identifierHash + * - Otherwise: returns contentHash and generates missing metadata for future exports + * + * @returns {Promise} Document unique identifier */ async getDocumentIdentifier() { - if (this.documentGuid) { - return this.documentGuid; + // Return cached identifier if already computed + if (this.documentUniqueIdentifier) { + return this.documentUniqueIdentifier; } - if (!this.documentHash && this.fileSource) { - this.documentHash = await this.#generateDocumentHash(); + // 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(); + } 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(); } - 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 cb0cee48c5..fb550a8da3 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', () => { 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 9b65b04b92..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,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 (defaults to community key) + licenseKey: COMMUNITY_LICENSE_KEY, + + // Telemetry settings + telemetry: { enabled: false }, // Enable to track document opens + title: 'SuperDoc', conversations: [], isInternal: false, @@ -319,7 +325,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/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..dc9184322e 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 - 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 d5265acc2c..0d202d1734 100644 --- a/packages/superdoc/src/dev/components/SuperdocDev.vue +++ b/packages/superdoc/src/dev/components/SuperdocDev.vue @@ -153,19 +153,22 @@ const init = async () => { // eslint-disable-next-line no-unused-vars const testDocumentId = 'doc123'; - // Prepare document config with content if available - const documentConfig = { - data: currentFile.value, - id: testId, - isNewFile: true, - }; - - // 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 = { @@ -175,6 +178,10 @@ const init = async () => { toolbarGroups: ['center'], role: userRole, documentMode: 'editing', + licenseKey: 'community-and-eval-agplv3', + telemetry: { + enabled: false, + }, comments: { visible: true, }, @@ -200,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', @@ -525,8 +532,8 @@ onMounted(async () => { console.log('[collab] Provider synced, initializing SuperDoc'); } - const blankFile = await getFileObject(BlankDOCX, 'test.docx', DOCX); - handleNewFile(blankFile); + // Initialize SuperDoc - it will automatically create a blank document + init(); }); onBeforeUnmount(() => { @@ -774,7 +781,7 @@ if (scrollTestMode.value) {
-
+
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..7d76cf5db5 100644 --- a/shared/common/Telemetry.d.ts +++ b/shared/common/Telemetry.d.ts @@ -1,275 +1,53 @@ -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 | 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; + documentId: string | null; + documentCreatedAt: string | null; } -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[]; -} -export type TelemetryPayload = TelemetryUsageEvent | TelemetryParsingReport[]; -export interface TelemetryError extends ReadonlyTelemetryRecord { - readonly message?: string; - readonly elementName?: string; - readonly attributes?: TelemetryAttributes; - readonly timestamp?: string; -} -export interface UnknownElement { - readonly elementName: string; - count: number; - attributes?: TelemetryAttributes; + +export interface TelemetryPayload { + superdocVersion: string; + browserInfo: BrowserInfo; + metadata?: Record; + events: DocumentOpenEvent[]; } + /** - * Discriminated union for statistic data based on category + * Community license key for AGPLv3 / evaluation usage. */ -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 const COMMUNITY_LICENSE_KEY: 'community-and-eval-agplv3'; + 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; - /** - * Sends current report - * @returns Promise that resolves when report is sent - */ - sendReport(): Promise; - /** - * Sends data to the service - * @param data - Payload to send - * @returns Promise that resolves when data is sent - */ - sendDataToTelemetry(data: TelemetryPayload): Promise; - /** - * Generate unique identifier - * @returns Unique ID - * @private - */ - generateId(): string; - /** - * Reset statistics + * 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 */ - resetStatistics(): void; + trackDocumentOpen(documentId: string | null, documentCreatedAt?: string | null): void; } -export {}; diff --git a/shared/common/Telemetry.test.ts b/shared/common/Telemetry.test.ts index 82c3e24c01..11efb4d897 100644 --- a/shared/common/Telemetry.test.ts +++ b/shared/common/Telemetry.test.ts @@ -1,96 +1,225 @@ -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({ ...testConfig, enabled: false }); + 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(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(); + }); + }); + + describe('telemetry without license key', () => { + it('sends telemetry when enabled without license key', async () => { + const telemetry = new Telemetry({ 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(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(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({ 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, - }); - } else { - vi.spyOn(globalThis.crypto, 'getRandomValues').mockImplementation(getRandomValues); - } - - const telemetry = new Telemetry(baseConfig); - const id = telemetry.generateId(); - - expect(getRandomValues).toHaveBeenCalled(); - expect(id.split('-')[1]).toBe('aabbccdd'); + it('sends telemetry to default endpoint when not provided', async () => { + const telemetry = new Telemetry({ enabled: true }); + telemetry.trackDocumentOpen('doc-123'); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://ingest.superdoc.dev/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(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' }); + }); + + it('omits metadata from payload when not provided', async () => { + const telemetry = new Telemetry(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.metadata).toBeUndefined(); }); - (globalThis as typeof globalThis & { msCrypto?: Crypto }).msCrypto = undefined; - const mathRandomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.5); + it('includes empty metadata object when provided as empty', async () => { + const telemetry = new Telemetry({ ...testConfig, metadata: {} }); + 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).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(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(); }); - // Set up msCrypto (legacy IE11 support) - const getRandomValues = vi.fn((array: Uint8Array) => { - array.set([0x11, 0x22, 0x33, 0x44]); - return array; + it('includes superdocVersion at root level', async () => { + const telemetry = new Telemetry(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(); }); + }); - (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(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..25dec3c652 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; + documentId: string | null; + documentCreatedAt: string | null; } -export interface TelemetryParsingReport extends BaseTelemetryEvent { - type: 'parsing'; - statistics: Statistics; - fileStructure: FileStructure; - unknownElements: UnknownElement[]; - errors: TelemetryError[]; +export interface TelemetryPayload { + superdocVersion: string; + browserInfo: BrowserInfo; + metadata?: Record; + events: DocumentOpenEvent[]; } -export type TelemetryPayload = TelemetryUsageEvent | TelemetryParsingReport[]; - -export interface TelemetryError extends ReadonlyTelemetryRecord { - readonly message?: string; - readonly elementName?: string; - readonly attributes?: TelemetryAttributes; - readonly timestamp?: string; -} - -export interface UnknownElement { - readonly elementName: string; - count: number; - attributes?: TelemetryAttributes; -} +const DEFAULT_ENDPOINT = 'https://ingest.superdoc.dev/v1/collect'; /** - * Discriminated union for statistic data based on category + * Community license key for AGPLv3 / evaluation usage. */ -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; - - if (cryptoObj && typeof cryptoObj.getRandomValues === 'function') { - return cryptoObj; - } - - return undefined; -} - -function randomBytes(length: number): Uint8Array { - const array = new Uint8Array(length); - const cryptoObj = getCrypto(); - - if (cryptoObj) { - cryptoObj.getRandomValues(array); - return array; - } +export const COMMUNITY_LICENSE_KEY = 'community-and-eval-agplv3'; - // Final fallback for runtimes without secure entropy (legacy tests, etc.) - for (let i = 0; i < length; i++) { - array[i] = Math.floor(Math.random() * 256); +function getSuperdocVersion(): string { + try { + return typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'unknown'; + } catch { + return 'unknown'; } - - 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; + private enabled: boolean; + private endpoint: string; + private superdocVersion: string; + private licenseKey: string; + private metadata?: Record; - 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(); + this.enabled = config.enabled; + this.endpoint = config.endpoint || DEFAULT_ENDPOINT; + this.licenseKey = config.licenseKey || ''; + this.metadata = 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,267 +90,45 @@ 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); - } - - /** - * 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, '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, - }); + this.sendEvent(event); } /** - * Process document metadata - * @param file - Document file - * @param options - Additional options - * @returns Document metadata + * Send event via fetch (fire and forget) */ - 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 { try { - const response = await fetch(this.endpoint, { + await fetch(this.endpoint, { method: 'POST', headers: { '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(); - } - } catch (error) { - console.error('Failed to upload telemetry:', error); + } catch { + // Fail silently - telemetry should never break the app } } - - /** - * Generate unique identifier - * @returns Unique ID - * @private - */ - generateId(): string { - const timestamp = Date.now(); - const random = Array.from(randomBytes(4)) - .map((b) => b.toString(16).padStart(2, '0')) - .join(''); - return `${timestamp}-${random}`; - } - - /** - * Reset statistics - */ - resetStatistics(): void { - this.statistics = { - nodeTypes: {}, - markTypes: {}, - attributes: {}, - errorCount: 0, - }; - - this.fileStructure = { - totalFiles: 0, - maxDepth: 0, - totalNodes: 0, - files: [], - }; - - this.unknownElements = []; - - this.errors = []; - } } diff --git a/shared/common/index.ts b/shared/common/index.ts index 9ce16438df..690f4a320c 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, COMMUNITY_LICENSE_KEY } from './Telemetry'; +export type { TelemetryConfig, TelemetryPayload, DocumentOpenEvent, BrowserInfo } from './Telemetry';