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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 205 additions & 0 deletions packages/super-editor/src/core/Editor.telemetry.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> } | 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' },
});
});
});
});
81 changes: 80 additions & 1 deletion packages/super-editor/src/core/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -243,6 +244,16 @@ export class Editor extends EventEmitter<EditorEventMap> {
*/
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,
Expand Down Expand Up @@ -327,6 +338,12 @@ export class Editor extends EventEmitter<EditorEventMap> {

// 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,
};

/**
Expand Down Expand Up @@ -393,6 +410,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
this.#checkHeadless(resolvedOptions);
this.setOptions(resolvedOptions);
this.#renderer = resolvedOptions.renderer ?? (domAvailable ? new ProseMirrorRenderer() : null);
this.#initTelemetry();

const { setHighContrastMode } = useHighContrastMode();
this.setHighContrastMode = setHighContrastMode;
Expand Down Expand Up @@ -454,6 +472,56 @@ export class Editor extends EventEmitter<EditorEventMap> {
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) {
return;
}

try {
this.#telemetry = 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
}
}

/**
* 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;
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
}
});
}

/**
Expand Down Expand Up @@ -992,6 +1060,9 @@ export class Editor extends EventEmitter<EditorEventMap> {
if (this.isDestroyed) return;
this.emit('create', { editor: this });
}, 0);

// Generate metadata and track telemetry (non-blocking)
this.#trackDocumentOpen();
}

unmount(): void {
Expand Down Expand Up @@ -1553,6 +1624,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
documentId: this.options.documentId,
mockWindow: this.options.mockWindow ?? null,
mockDocument: this.options.mockDocument ?? null,
isNewFile: this.options.isNewFile ?? false,
});
}
}
Expand Down Expand Up @@ -2118,7 +2190,8 @@ export class Editor extends EventEmitter<EditorEventMap> {
}

/**
* 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<string | null> {
return (await this.converter?.getDocumentIdentifier()) || null;
Expand Down Expand Up @@ -2521,6 +2594,11 @@ export class Editor extends EventEmitter<EditorEventMap> {

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<string, string> = {
...this.options.customUpdatedFiles,
'word/document.xml': String(documentXml),
Expand All @@ -2531,6 +2609,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
// Replace & with &amp; in styles.xml as DOCX viewers can't handle it
'word/styles.xml': String(styles).replace(/&/gi, '&amp;'),
...updatedHeadersFooters,
...(coreXml ? { 'docProps/core.xml': String(coreXml) } : {}),
};

if (hasCustomSettings) {
Expand Down
Loading