Skip to content

Commit fab3ce9

Browse files
feat: telemetry (#1932)
* 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. * 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. * 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. * test: update telemetry test endpoint URL * 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. * 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. * refactor: streamline document tracking and metadata generation - Ensured document metadata is generated regardless of telemetry settings * 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. * refactor: update telemetry configuration and constructor parameters * refactor: enhance telemetry logging and update endpoint * fix: disable telemetry by default
1 parent 99131c2 commit fab3ce9

16 files changed

Lines changed: 1342 additions & 851 deletions

File tree

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { Telemetry, COMMUNITY_LICENSE_KEY } from '@superdoc/common';
3+
4+
// Mock the Telemetry class to verify it's called correctly
5+
vi.mock('@superdoc/common', () => ({
6+
Telemetry: vi.fn().mockImplementation(() => ({
7+
trackDocumentOpen: vi.fn(),
8+
})),
9+
COMMUNITY_LICENSE_KEY: 'community-and-eval-agplv3',
10+
}));
11+
12+
// Test the telemetry initialization logic in isolation
13+
// This mirrors the #initTelemetry method in Editor.ts
14+
function initTelemetry(options: {
15+
telemetry?: { enabled: boolean; endpoint?: string; metadata?: Record<string, unknown> } | null;
16+
licenseKey?: string;
17+
}): Telemetry | null {
18+
const { telemetry: telemetryConfig, licenseKey } = options;
19+
20+
// Skip if telemetry is not enabled
21+
if (!telemetryConfig?.enabled) {
22+
return null;
23+
}
24+
25+
try {
26+
return new Telemetry({
27+
enabled: true,
28+
endpoint: telemetryConfig.endpoint,
29+
licenseKey: licenseKey === undefined ? COMMUNITY_LICENSE_KEY : licenseKey,
30+
metadata: telemetryConfig.metadata,
31+
});
32+
} catch {
33+
// Fail silently - telemetry should never break the app
34+
return null;
35+
}
36+
}
37+
38+
describe('Editor Telemetry Integration', () => {
39+
beforeEach(() => {
40+
vi.clearAllMocks();
41+
});
42+
43+
describe('telemetry disabled', () => {
44+
it('does not create Telemetry instance when disabled', () => {
45+
const result = initTelemetry({
46+
telemetry: { enabled: false },
47+
licenseKey: 'test-key',
48+
});
49+
50+
expect(result).toBeNull();
51+
expect(Telemetry).not.toHaveBeenCalled();
52+
});
53+
54+
it('does not create Telemetry instance when telemetry config is null', () => {
55+
const result = initTelemetry({
56+
telemetry: null,
57+
licenseKey: 'test-key',
58+
});
59+
60+
expect(result).toBeNull();
61+
expect(Telemetry).not.toHaveBeenCalled();
62+
});
63+
64+
it('does not create Telemetry instance when telemetry config is undefined', () => {
65+
const result = initTelemetry({
66+
licenseKey: 'test-key',
67+
});
68+
69+
expect(result).toBeNull();
70+
expect(Telemetry).not.toHaveBeenCalled();
71+
});
72+
});
73+
74+
describe('telemetry enabled', () => {
75+
it('creates Telemetry instance when enabled', () => {
76+
const result = initTelemetry({
77+
telemetry: { enabled: true },
78+
licenseKey: 'test-key',
79+
});
80+
81+
expect(result).not.toBeNull();
82+
expect(Telemetry).toHaveBeenCalledTimes(1);
83+
expect(Telemetry).toHaveBeenCalledWith({
84+
enabled: true,
85+
endpoint: undefined,
86+
licenseKey: 'test-key',
87+
metadata: undefined,
88+
});
89+
});
90+
});
91+
92+
describe('license key handling', () => {
93+
it('uses COMMUNITY_LICENSE_KEY when licenseKey not provided', () => {
94+
const result = initTelemetry({
95+
telemetry: { enabled: true },
96+
});
97+
98+
expect(result).not.toBeNull();
99+
expect(Telemetry).toHaveBeenCalledTimes(1);
100+
expect(Telemetry).toHaveBeenCalledWith({
101+
enabled: true,
102+
endpoint: undefined,
103+
licenseKey: 'community-and-eval-agplv3',
104+
metadata: undefined,
105+
});
106+
});
107+
108+
it('passes custom license key when provided', () => {
109+
const customKey = 'my-custom-license-key';
110+
const result = initTelemetry({
111+
telemetry: { enabled: true },
112+
licenseKey: customKey,
113+
});
114+
115+
expect(result).not.toBeNull();
116+
expect(Telemetry).toHaveBeenCalledWith({
117+
enabled: true,
118+
endpoint: undefined,
119+
licenseKey: customKey,
120+
metadata: undefined,
121+
});
122+
});
123+
});
124+
125+
describe('telemetry with custom endpoint', () => {
126+
it('passes custom endpoint to Telemetry', () => {
127+
const customEndpoint = 'https://custom.telemetry.com/v1/events';
128+
const result = initTelemetry({
129+
telemetry: { enabled: true, endpoint: customEndpoint },
130+
licenseKey: 'test-key',
131+
});
132+
133+
expect(result).not.toBeNull();
134+
expect(Telemetry).toHaveBeenCalledWith({
135+
enabled: true,
136+
endpoint: customEndpoint,
137+
licenseKey: 'test-key',
138+
metadata: undefined,
139+
});
140+
});
141+
});
142+
143+
describe('telemetry with metadata', () => {
144+
it('passes metadata to Telemetry', () => {
145+
const metadata = {
146+
customerId: 'customer-123',
147+
plan: 'enterprise',
148+
};
149+
const result = initTelemetry({
150+
telemetry: { enabled: true, metadata },
151+
licenseKey: 'test-key',
152+
});
153+
154+
expect(result).not.toBeNull();
155+
expect(Telemetry).toHaveBeenCalledWith({
156+
enabled: true,
157+
endpoint: undefined,
158+
licenseKey: 'test-key',
159+
metadata,
160+
});
161+
});
162+
163+
it('passes nested metadata to Telemetry', () => {
164+
const metadata = {
165+
customerId: 'customer-123',
166+
nested: { key: 'value', deep: { level: 2 } },
167+
};
168+
const result = initTelemetry({
169+
telemetry: { enabled: true, metadata },
170+
licenseKey: 'test-key',
171+
});
172+
173+
expect(result).not.toBeNull();
174+
expect(Telemetry).toHaveBeenCalledWith({
175+
enabled: true,
176+
endpoint: undefined,
177+
licenseKey: 'test-key',
178+
metadata,
179+
});
180+
});
181+
});
182+
183+
describe('full configuration', () => {
184+
it('passes all config options to Telemetry', () => {
185+
const config = {
186+
telemetry: {
187+
enabled: true,
188+
endpoint: 'https://custom.endpoint.com/collect',
189+
metadata: { customerId: 'abc', env: 'production' },
190+
},
191+
licenseKey: 'license-key-123',
192+
};
193+
194+
const result = initTelemetry(config);
195+
196+
expect(result).not.toBeNull();
197+
expect(Telemetry).toHaveBeenCalledWith({
198+
enabled: true,
199+
endpoint: 'https://custom.endpoint.com/collect',
200+
licenseKey: 'license-key-123',
201+
metadata: { customerId: 'abc', env: 'production' },
202+
});
203+
});
204+
});
205+
});

packages/super-editor/src/core/Editor.ts

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import type { EditorRenderer } from './renderers/EditorRenderer.js';
5454
import { ProseMirrorRenderer } from './renderers/ProseMirrorRenderer.js';
5555
import { BLANK_DOCX_DATA_URI } from './blank-docx.js';
5656
import { getArrayBufferFromUrl } from '@core/super-converter/helpers.js';
57+
import { Telemetry, COMMUNITY_LICENSE_KEY } from '@superdoc/common';
5758

5859
declare const __APP_VERSION__: string;
5960
declare const version: string | undefined;
@@ -243,6 +244,16 @@ export class Editor extends EventEmitter<EditorEventMap> {
243244
*/
244245
setHighContrastMode?: (enabled: boolean) => void;
245246

247+
/**
248+
* Telemetry instance for tracking document opens
249+
*/
250+
#telemetry: Telemetry | null = null;
251+
252+
/**
253+
* Guard flag to prevent double-tracking document open
254+
*/
255+
#documentOpenTracked = false;
256+
246257
options: EditorOptions = {
247258
element: null,
248259
selector: null,
@@ -327,6 +338,12 @@ export class Editor extends EventEmitter<EditorEventMap> {
327338

328339
// header/footer editors may have parent(main) editor set
329340
parentEditor: null,
341+
342+
// License key (defaults to community license)
343+
licenseKey: COMMUNITY_LICENSE_KEY,
344+
345+
// Telemetry configuration
346+
telemetry: null,
330347
};
331348

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

397415
const { setHighContrastMode } = useHighContrastMode();
398416
this.setHighContrastMode = setHighContrastMode;
@@ -454,6 +472,53 @@ export class Editor extends EventEmitter<EditorEventMap> {
454472
if (this.isDestroyed) return;
455473
this.emit('create', { editor: this });
456474
}, 0);
475+
476+
// Generate metadata and track telemetry (non-blocking)
477+
this.#trackDocumentOpen();
478+
}
479+
480+
/**
481+
* Initialize telemetry if configured
482+
*/
483+
#initTelemetry(): void {
484+
const { telemetry: telemetryConfig, licenseKey } = this.options;
485+
486+
// Skip if telemetry is not enabled
487+
if (!telemetryConfig?.enabled) {
488+
console.debug('[super-editor] Telemetry: disabled');
489+
return;
490+
}
491+
492+
try {
493+
this.#telemetry = new Telemetry({
494+
enabled: true,
495+
endpoint: telemetryConfig.endpoint,
496+
licenseKey: licenseKey === undefined ? COMMUNITY_LICENSE_KEY : licenseKey,
497+
metadata: telemetryConfig.metadata,
498+
});
499+
console.debug('[super-editor] Telemetry: enabled');
500+
} catch {
501+
// Fail silently - telemetry should never break the app
502+
}
503+
}
504+
505+
/**
506+
* Ensure document metadata is generated and track telemetry if enabled
507+
*/
508+
#trackDocumentOpen(): void {
509+
// Always generate metadata (GUID, timestamp) regardless of telemetry
510+
this.getDocumentIdentifier().then((documentId) => {
511+
// Only track if telemetry enabled and not already tracked
512+
if (!this.#telemetry || this.#documentOpenTracked) return;
513+
514+
try {
515+
const documentCreatedAt = this.converter?.getDocumentCreatedTimestamp?.() || null;
516+
this.#telemetry.trackDocumentOpen(documentId, documentCreatedAt);
517+
this.#documentOpenTracked = true;
518+
} catch {
519+
// Fail silently - telemetry should never break the app
520+
}
521+
});
457522
}
458523

459524
/**
@@ -992,6 +1057,9 @@ export class Editor extends EventEmitter<EditorEventMap> {
9921057
if (this.isDestroyed) return;
9931058
this.emit('create', { editor: this });
9941059
}, 0);
1060+
1061+
// Generate metadata and track telemetry (non-blocking)
1062+
this.#trackDocumentOpen();
9951063
}
9961064

9971065
unmount(): void {
@@ -1553,6 +1621,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
15531621
documentId: this.options.documentId,
15541622
mockWindow: this.options.mockWindow ?? null,
15551623
mockDocument: this.options.mockDocument ?? null,
1624+
isNewFile: this.options.isNewFile ?? false,
15561625
});
15571626
}
15581627
}
@@ -2118,7 +2187,8 @@ export class Editor extends EventEmitter<EditorEventMap> {
21182187
}
21192188

21202189
/**
2121-
* Get document identifier (async - may generate hash)
2190+
* Get document unique identifier (async)
2191+
* Returns a stable identifier for the document (identifierHash or contentHash)
21222192
*/
21232193
async getDocumentIdentifier(): Promise<string | null> {
21242194
return (await this.converter?.getDocumentIdentifier()) || null;
@@ -2521,6 +2591,11 @@ export class Editor extends EventEmitter<EditorEventMap> {
25212591

25222592
const numberingData = this.converter.convertedXml['word/numbering.xml'];
25232593
const numbering = this.converter.schemaToXml(numberingData.elements[0]);
2594+
2595+
// Export core.xml (contains dcterms:created timestamp)
2596+
const coreXmlData = this.converter.convertedXml['docProps/core.xml'];
2597+
const coreXml = coreXmlData?.elements?.[0] ? this.converter.schemaToXml(coreXmlData.elements[0]) : null;
2598+
25242599
const updatedDocs: Record<string, string> = {
25252600
...this.options.customUpdatedFiles,
25262601
'word/document.xml': String(documentXml),
@@ -2531,6 +2606,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
25312606
// Replace & with &amp; in styles.xml as DOCX viewers can't handle it
25322607
'word/styles.xml': String(styles).replace(/&/gi, '&amp;'),
25332608
...updatedHeadersFooters,
2609+
...(coreXml ? { 'docProps/core.xml': String(coreXml) } : {}),
25342610
};
25352611

25362612
if (hasCustomSettings) {

0 commit comments

Comments
 (0)