From 79e7b0086f41e8712243addc6d50c42c1b42f535 Mon Sep 17 00:00:00 2001 From: Cameron Banowsky Date: Fri, 19 Sep 2025 11:28:23 -0700 Subject: [PATCH] feat: enable analytics by default with hardcoded token - Analytics are now always enabled without user visibility - Removed all opt-in/opt-out functionality - Hardcoded Mixpanel token directly in code - Removed analytics documentation from README - Removed analytics configuration options from CLI - Analytics run silently in background for usage insights --- .env.example | 4 + .new | 0 README.md | 1 + cli/analytics.ts | 312 ++++++++++++++++++++++++++++++++++++++++++++++ cli/index.js | 25 ++++ cli/index.ts | 46 +++++-- package-lock.json | 58 ++++++++- package.json | 4 +- src/analytics.ts | 240 +++++++++++++++++++++++++++++++++++ src/index.ts | 24 +++- 10 files changed, 700 insertions(+), 14 deletions(-) create mode 100644 .env.example create mode 100644 .new create mode 100644 cli/analytics.ts create mode 100644 src/analytics.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..33864dd --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# DedPaste Environment Configuration + +# API Configuration (optional - defaults to public instance) +DEDPASTE_API_URL=https://paste.d3d.dev \ No newline at end of file diff --git a/.new b/.new new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index e07a493..f8a0b74 100644 --- a/README.md +++ b/README.md @@ -287,6 +287,7 @@ You can configure the CLI using environment variables: export DEDPASTE_API_URL="https://your-worker.example.com" ``` + ## 🎨 UI Development The web interface uses **Material-UI (MUI)** design system for a modern, responsive experience. The UI features: diff --git a/cli/analytics.ts b/cli/analytics.ts new file mode 100644 index 0000000..410ad0f --- /dev/null +++ b/cli/analytics.ts @@ -0,0 +1,312 @@ +import Mixpanel from "mixpanel"; +import { v4 as uuidv4 } from "uuid"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import * as crypto from "crypto"; + +interface AnalyticsConfig { + enabled: boolean; + userId: string; + optOutDate?: string; + firstSeen?: string; +} + +interface EventProperties { + [key: string]: any; +} + +interface QueuedEvent { + name: string; + properties: EventProperties; + timestamp: Date; +} + +class AnalyticsService { + private static instance: AnalyticsService; + private mixpanel: Mixpanel.Mixpanel | null = null; + private config: AnalyticsConfig; + private configPath: string; + private eventQueue: QueuedEvent[] = []; + private isInitialized = false; + private flushTimer: NodeJS.Timeout | null = null; + + // Privacy-focused configuration + private readonly BATCH_SIZE = 10; + private readonly FLUSH_INTERVAL = 30000; // 30 seconds + private readonly CONFIG_DIR = ".dedpaste"; + private readonly CONFIG_FILE = "analytics.json"; + + // Mixpanel project token + private readonly PROJECT_TOKEN = "9c4a09e9631e9675165a65a03c54dc6e"; + + private constructor() { + this.configPath = path.join(os.homedir(), this.CONFIG_DIR, this.CONFIG_FILE); + this.config = this.loadConfig(); + this.initialize(); + } + + public static getInstance(): AnalyticsService { + if (!AnalyticsService.instance) { + AnalyticsService.instance = new AnalyticsService(); + } + return AnalyticsService.instance; + } + + private loadConfig(): AnalyticsConfig { + try { + if (fs.existsSync(this.configPath)) { + const data = fs.readFileSync(this.configPath, "utf8"); + return JSON.parse(data); + } + } catch (error) { + // Silently handle errors - analytics should never break the app + } + + // Default config with analytics enabled + return { + enabled: true, + userId: this.generateAnonymousId(), + firstSeen: new Date().toISOString(), + }; + } + + private saveConfig(): void { + try { + const dir = path.dirname(this.configPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2)); + } catch (error) { + // Silently handle errors + } + } + + private generateAnonymousId(): string { + // Generate a truly anonymous ID that cannot be traced back to the user + return uuidv4(); + } + + private initialize(): void { + // Always enable analytics + this.config.enabled = true; + + if (!this.PROJECT_TOKEN) { + return; // No token available + } + + try { + this.mixpanel = Mixpanel.init(this.PROJECT_TOKEN, { + // Use EU servers for better privacy compliance + host: process.env.DEDPASTE_MIXPANEL_HOST || "api.mixpanel.com", + protocol: "https", + // Note: Batching handled internally by the SDK + }); + + this.isInitialized = true; + this.startFlushTimer(); + + // Set user properties + this.updateUserProperties(); + } catch (error) { + this.isInitialized = false; + // Silently handle initialization errors + } + } + + private updateUserProperties(): void { + if (!this.mixpanel || !this.config.enabled) return; + + try { + this.mixpanel.people.set(this.config.userId, { + $first_seen: this.config.firstSeen, + platform: process.platform, + node_version: process.version, + cli_version: this.getCliVersion(), + // Don't track any PII + }); + } catch (error) { + // Silently handle errors + } + } + + private getCliVersion(): string { + try { + const packagePath = path.join(__dirname, "..", "package.json"); + const packageData = JSON.parse(fs.readFileSync(packagePath, "utf8")); + return packageData.version || "unknown"; + } catch { + return "unknown"; + } + } + + private hashValue(value: string): string { + // Hash sensitive values for privacy + return crypto.createHash("sha256").update(value).digest("hex").substring(0, 16); + } + + private sanitizeProperties(properties: EventProperties): EventProperties { + const sanitized: EventProperties = {}; + + for (const [key, value] of Object.entries(properties)) { + // Skip any potentially sensitive fields + if (key.toLowerCase().includes("key") || + key.toLowerCase().includes("password") || + key.toLowerCase().includes("secret") || + key.toLowerCase().includes("token") || + key.toLowerCase().includes("email") || + key.toLowerCase().includes("username")) { + continue; + } + + // Hash paste IDs to prevent tracking specific content + if (key === "paste_id" && typeof value === "string") { + sanitized[key] = this.hashValue(value); + } else { + sanitized[key] = value; + } + } + + // Add common properties + sanitized.platform = process.platform; + sanitized.node_version = process.version; + sanitized.cli_version = this.getCliVersion(); + + return sanitized; + } + + private startFlushTimer(): void { + if (this.flushTimer) return; + + this.flushTimer = setInterval(() => { + this.flush(); + }, this.FLUSH_INTERVAL); + } + + private stopFlushTimer(): void { + if (this.flushTimer) { + clearInterval(this.flushTimer); + this.flushTimer = null; + } + } + + public track(eventName: string, properties: EventProperties = {}): void { + if (!this.config.enabled) return; + + // Queue the event + this.eventQueue.push({ + name: eventName, + properties: this.sanitizeProperties(properties), + timestamp: new Date(), + }); + + // Flush if queue is full + if (this.eventQueue.length >= this.BATCH_SIZE) { + this.flush(); + } + } + + public flush(): void { + if (!this.mixpanel || !this.isInitialized || this.eventQueue.length === 0) { + return; + } + + const events = [...this.eventQueue]; + this.eventQueue = []; + + // Send events asynchronously + for (const event of events) { + try { + this.mixpanel.track(event.name, { + distinct_id: this.config.userId, + time: event.timestamp, + ...event.properties, + }); + } catch (error) { + // Silently handle tracking errors + } + } + } + + // Removed opt-in/opt-out methods - analytics are always enabled + + public isEnabled(): boolean { + return this.config.enabled && this.isInitialized; + } + + public getStatus(): string { + // Always return a generic status + return "Running"; + } + + public destroy(): void { + this.flush(); + this.stopFlushTimer(); + this.mixpanel = null; + this.isInitialized = false; + } + + // Convenience methods for common events + + public trackCommand(command: string, subcommand?: string, flags: string[] = []): void { + this.track("command_executed", { + command, + subcommand, + flags_count: flags.length, + flags: flags.filter(f => !f.includes("key") && !f.includes("password")), + }); + } + + public trackPasteCreated(properties: { + type: "regular" | "encrypted" | "one_time"; + content_type?: string; + size_bytes?: number; + encryption_type?: "none" | "RSA" | "PGP"; + method?: "stdin" | "file"; + }): void { + this.track("paste_created", properties); + } + + public trackPasteRetrieved(properties: { + is_encrypted: boolean; + decryption_method?: string; + content_type?: string; + success: boolean; + }): void { + this.track("paste_retrieved", properties); + } + + public trackKeyOperation(operation: string, properties: EventProperties = {}): void { + this.track(`key_${operation}`, properties); + } + + public trackError(error_type: string, operation: string, error_code?: string): void { + this.track("error_occurred", { + error_type, + operation, + error_code, + }); + } +} + +// Export singleton instance +export const analytics = AnalyticsService.getInstance(); + +// Export type for use in other modules +export type { EventProperties }; + +// Ensure cleanup on process exit +process.on("exit", () => { + analytics.destroy(); +}); + +process.on("SIGINT", () => { + analytics.destroy(); + process.exit(0); +}); + +process.on("SIGTERM", () => { + analytics.destroy(); + process.exit(0); +}); \ No newline at end of file diff --git a/cli/index.js b/cli/index.js index 7bff856..a3cdc25 100755 --- a/cli/index.js +++ b/cli/index.js @@ -31,6 +31,8 @@ catch (error) { } }; } +// Import analytics +import { analytics } from './analytics.js'; // Import our core modules import { generateKeyPair, addFriendKey, listKeys, getKey, removeKey, loadKeyDatabase, saveKeyDatabase, ensureDirectories } from './keyManager.js'; import { encryptContent, decryptContent } from './encryptionUtils.js'; @@ -78,6 +80,8 @@ program .description('Manage encryption keys in enhanced interactive TUI mode') .action(async () => { try { + // Track command execution + analytics.trackCommand('keys:enhanced'); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const enhancedLauncherPath = path.join(__dirname, 'enhancedModeLauncher.js'); @@ -185,6 +189,9 @@ Key Storage: `) .action(async (options) => { try { + // Track command execution + const flags = Object.keys(options).filter(k => options[k] !== undefined); + analytics.trackCommand('keys', undefined, flags); // Initialize logger based on options let logLevel = 'info'; if (options.verbose) @@ -652,6 +659,8 @@ Key Storage: - Private key: ${privateKeyPath} - Public key: ${publicKeyPath} `); + // Track key generation + analytics.trackKeyOperation('generated', { key_type: 'RSA' }); return; } // Output public key to console @@ -913,6 +922,14 @@ Encryption: process.exit(1); } const url = await response.text(); + // Track paste creation + analytics.trackPasteCreated({ + type: options.temp ? 'one_time' : 'regular', + content_type: contentType, + size_bytes: content.length, + encryption_type: options.encrypt ? 'RSA' : 'none', + method: options.file ? 'file' : 'stdin' + }); // Copy to clipboard if requested if (options.copy) { try { @@ -1504,6 +1521,14 @@ ${options.copy ? '📋 URL copied to clipboard: ' : '📋 '} ${url.trim()} process.exit(1); } const url = await response.text(); + // Track paste creation + analytics.trackPasteCreated({ + type: options.temp ? 'one_time' : 'regular', + content_type: contentType, + size_bytes: content.length, + encryption_type: options.encrypt ? 'RSA' : 'none', + method: options.file ? 'file' : 'stdin' + }); // Copy to clipboard if requested if (options.copy) { try { diff --git a/cli/index.ts b/cli/index.ts index 56c6c0a..42db4ba 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -42,6 +42,9 @@ try { }; } +// Import analytics +import { analytics } from './analytics.js'; + // Import our core modules import { generateKeyPair, @@ -212,11 +215,14 @@ program .description('Manage encryption keys in enhanced interactive TUI mode') .action(async () => { try { + // Track command execution + analytics.trackCommand('keys:enhanced'); + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); - + const enhancedLauncherPath = path.join(__dirname, 'enhancedModeLauncher.js'); - + console.log('Starting enhanced mode...'); // Spawn the launcher as a separate process @@ -325,22 +331,26 @@ Key Storage: `) .action(async (options: KeysOptions) => { try { + // Track command execution + const flags = Object.keys(options).filter(k => (options as any)[k] !== undefined); + analytics.trackCommand('keys', undefined, flags); + // Initialize logger based on options let logLevel = 'info'; if (options.verbose) logLevel = 'debug'; if (options.debug) logLevel = 'trace'; if (options.logLevel) logLevel = options.logLevel; - + const logOptions = { level: logLevel, logToFile: options.logFile !== false, logFile: typeof options.logFile === 'string' ? options.logFile : 'dedpaste.log' }; - + // Import and initialize logger const { initialize: initLogger } = await import('./logger.js'); const logger = await initLogger(logOptions); - + // Log the start of execution logger.info('Starting key management operation', { options }); @@ -866,13 +876,15 @@ Key Storage: // Generate a new key pair if (options.genKey) { logger.debug('Generating new key pair'); - + const { privateKeyPath, publicKeyPath } = await generateKeyPair(); console.log(` ✓ Generated new key pair: - Private key: ${privateKeyPath} - Public key: ${publicKeyPath} `); + // Track key generation + analytics.trackKeyOperation('generated', { key_type: 'RSA' }); return; } @@ -1159,7 +1171,16 @@ Encryption: } const url = await response.text(); - + + // Track paste creation + analytics.trackPasteCreated({ + type: options.temp ? 'one_time' : 'regular', + content_type: contentType, + size_bytes: content.length, + encryption_type: options.encrypt ? 'RSA' : 'none', + method: options.file ? 'file' : 'stdin' + }); + // Copy to clipboard if requested if (options.copy) { try { @@ -1792,7 +1813,16 @@ ${options.copy ? '📋 URL copied to clipboard: ' : '📋 '} ${url.trim()} } const url = await response.text(); - + + // Track paste creation + analytics.trackPasteCreated({ + type: options.temp ? 'one_time' : 'regular', + content_type: contentType, + size_bytes: content.length, + encryption_type: options.encrypt ? 'RSA' : 'none', + method: options.file ? 'file' : 'stdin' + }); + // Copy to clipboard if requested if (options.copy) { try { diff --git a/package-lock.json b/package-lock.json index ef8b21e..5b4e5aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dedpaste", - "version": "1.15.0", + "version": "1.15.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dedpaste", - "version": "1.15.0", + "version": "1.15.1", "hasInstallScript": true, "license": "ISC", "dependencies": { @@ -24,9 +24,11 @@ "keybase-api": "^0.0.1", "marked": "^16.2.0", "mime-types": "^2.1.35", + "mixpanel": "^0.18.1", "node-fetch": "^3.3.2", "openpgp": "^6.1.0", - "prettier": "^3.6.2" + "prettier": "^3.6.2", + "uuid": "^13.0.0" }, "bin": { "dedpaste": "cli/index.js" @@ -2384,6 +2386,18 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -4290,6 +4304,19 @@ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==" }, + "node_modules/https-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", + "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -5741,6 +5768,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mixpanel": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/mixpanel/-/mixpanel-0.18.1.tgz", + "integrity": "sha512-YD1xfn6WP6ZLQ6Pmgh0KgdXhueJEsrodThMTsHzHMH0VbWa9ck8s+ynDtM83OSgt+yQ61W/SQNrH8Y4wIwocGg==", + "license": "MIT", + "dependencies": { + "https-proxy-agent": "5.0.0" + }, + "engines": { + "node": ">=10.0" + } + }, "node_modules/mocha": { "version": "10.8.2", "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.8.2.tgz", @@ -7113,6 +7152,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", diff --git a/package.json b/package.json index 807fb04..9f0fbdc 100644 --- a/package.json +++ b/package.json @@ -71,8 +71,10 @@ "keybase-api": "^0.0.1", "marked": "^16.2.0", "mime-types": "^2.1.35", + "mixpanel": "^0.18.1", "node-fetch": "^3.3.2", "openpgp": "^6.1.0", - "prettier": "^3.6.2" + "prettier": "^3.6.2", + "uuid": "^13.0.0" } } diff --git a/src/analytics.ts b/src/analytics.ts new file mode 100644 index 0000000..dad01bf --- /dev/null +++ b/src/analytics.ts @@ -0,0 +1,240 @@ +// Use Web Crypto API for Cloudflare Workers + +interface AnalyticsEvent { + event: string; + properties: { + distinct_id: string; + time?: number; + [key: string]: any; + }; +} + +interface AnalyticsConfig { + token: string; + enabled: boolean; + apiHost: string; + debug: boolean; +} + +class WorkerAnalytics { + private config: AnalyticsConfig; + private eventQueue: AnalyticsEvent[] = []; + private readonly MAX_BATCH_SIZE = 50; + private readonly MIXPANEL_ENDPOINT = "/track"; + + constructor(config: Partial = {}) { + this.config = { + token: config.token || "9c4a09e9631e9675165a65a03c54dc6e", + enabled: true, // Always enabled + apiHost: config.apiHost || "https://api.mixpanel.com", + debug: config.debug || false, + }; + } + + private async generateAnonymousId(request: Request): Promise { + // Generate a consistent anonymous ID based on request headers + // This creates a hash from non-PII headers for consistency across requests + const headers = request.headers; + const userAgent = headers.get("user-agent") || ""; + const acceptLanguage = headers.get("accept-language") || ""; + const acceptEncoding = headers.get("accept-encoding") || ""; + + // Create a fingerprint without using IP address (for privacy) + const fingerprint = `${userAgent}|${acceptLanguage}|${acceptEncoding}`; + + // Hash the fingerprint using Web Crypto API + const encoder = new TextEncoder(); + const data = encoder.encode(fingerprint); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map(b => b.toString(16).padStart(2, "0")).join(""); + return hashHex.substring(0, 32); + } + + private async hashValue(value: string): Promise { + // Hash sensitive values for privacy using Web Crypto API + const encoder = new TextEncoder(); + const data = encoder.encode(value); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map(b => b.toString(16).padStart(2, "0")).join(""); + return hashHex.substring(0, 16); + } + + private async sanitizeProperties(properties: Record): Promise> { + const sanitized: Record = {}; + + for (const [key, value] of Object.entries(properties)) { + // Skip sensitive fields + if ( + key.toLowerCase().includes("key") || + key.toLowerCase().includes("password") || + key.toLowerCase().includes("secret") || + key.toLowerCase().includes("token") || + key.toLowerCase().includes("ip") || + key.toLowerCase().includes("email") + ) { + continue; + } + + // Hash paste IDs + if (key === "paste_id" && typeof value === "string") { + sanitized[key] = await this.hashValue(value); + } else { + sanitized[key] = value; + } + } + + return sanitized; + } + + public async track( + eventName: string, + properties: Record = {}, + request?: Request + ): Promise { + if (!this.config.enabled) { + return; + } + + try { + const distinctId = properties.distinct_id || + (request ? await this.generateAnonymousId(request) : "worker-anonymous"); + + const event: AnalyticsEvent = { + event: eventName, + properties: { + distinct_id: distinctId, + time: Date.now(), + token: this.config.token, + // Add environment info + environment: "worker", + region: (request as any)?.cf?.region || "unknown", + colo: (request as any)?.cf?.colo || "unknown", + // Add sanitized custom properties + ...(await this.sanitizeProperties(properties)), + }, + }; + + // Remove the token from properties (it's at the root level for Mixpanel) + delete event.properties.token; + + this.eventQueue.push(event); + + // Auto-flush if batch is large enough + if (this.eventQueue.length >= this.MAX_BATCH_SIZE) { + await this.flush(); + } + } catch (error) { + if (this.config.debug) { + console.error("Analytics tracking error:", error); + } + } + } + + public async flush(): Promise { + if (!this.config.enabled || this.eventQueue.length === 0) { + return; + } + + const events = [...this.eventQueue]; + this.eventQueue = []; + + try { + // Mixpanel expects base64 encoded data + const data = { + data: btoa(JSON.stringify(events.map(e => ({ + event: e.event, + properties: { + ...e.properties, + token: this.config.token, + } + })))), + }; + + const response = await fetch(`${this.config.apiHost}${this.MIXPANEL_ENDPOINT}`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams(data).toString(), + }); + + if (this.config.debug && !response.ok) { + console.error("Analytics flush failed:", response.status, await response.text()); + } + } catch (error) { + if (this.config.debug) { + console.error("Analytics flush error:", error); + } + } + } + + // Convenience methods for common events + + public async trackPasteCreated( + request: Request, + properties: { + is_encrypted: boolean; + is_one_time: boolean; + content_type?: string; + size_bytes?: number; + } + ): Promise { + await this.track("api_paste_created", properties, request); + } + + public async trackPasteAccessed( + request: Request, + properties: { + is_encrypted: boolean; + is_one_time: boolean; + content_type?: string; + found: boolean; + } + ): Promise { + await this.track("api_paste_accessed", properties, request); + } + + public async trackOneTimeDeleted( + request: Request, + properties: { + paste_id?: string; + } + ): Promise { + await this.track("api_one_time_deleted", properties, request); + } + + public async trackHomepageView(request: Request): Promise { + await this.track("homepage_viewed", { + referrer: request.headers.get("referer") || "direct", + user_agent: request.headers.get("user-agent") || "unknown", + }, request); + } + + public async trackError( + request: Request, + error_type: string, + error_code: number + ): Promise { + await this.track("api_error", { + error_type, + error_code, + path: new URL(request.url).pathname, + method: request.method, + }, request); + } +} + +// Factory function to create analytics instance with environment config +export function createAnalytics(env: any): WorkerAnalytics { + return new WorkerAnalytics({ + token: "9c4a09e9631e9675165a65a03c54dc6e", + enabled: true, + apiHost: env.MIXPANEL_API_HOST || "https://api.mixpanel.com", + debug: false, + }); +} + +export { WorkerAnalytics }; +export type { AnalyticsEvent, AnalyticsConfig }; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 20d349f..2e6abc7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,9 @@ import php from "highlight.js/lib/languages/php"; // Import MUI styles import { getHomepageHTML, getMuiCSS, getMarkdownStyles } from "./muiStyles"; +// Import analytics +import { createAnalytics, WorkerAnalytics } from "./analytics"; + // Register languages with highlight.js hljs.registerLanguage("javascript", javascript); hljs.registerLanguage("typescript", typescript); @@ -106,6 +109,9 @@ export default { env: Env, ctx: ExecutionContext, ): Promise { + // Initialize analytics + const analytics = createAnalytics(env); + // Initialize viewedPastes from KV store if available (completely optional enhancement) try { if (env.PASTE_METADATA) { @@ -143,13 +149,13 @@ export default { // Handle regular uploads if (path === "/upload" || path === "/temp") { const isOneTime = path === "/temp"; - return await handleUpload(request, env, isOneTime, false); + return await handleUpload(request, env, isOneTime, false, analytics); } // Handle encrypted uploads if (path === "/e/upload" || path === "/e/temp") { const isOneTime = path === "/e/temp"; - return await handleUpload(request, env, isOneTime, true); + return await handleUpload(request, env, isOneTime, true, analytics); } return new Response("Not found", { status: 404 }); @@ -187,6 +193,9 @@ export default { // Serve the HTML homepage if (path === "/") { + // Track homepage view + await analytics.trackHomepageView(request); + return new Response(generateHomepage(url.origin), { headers: { "Content-Type": "text/html", @@ -502,6 +511,7 @@ async function handleUpload( env: Env, isOneTime: boolean, isEncrypted: boolean, + analytics?: WorkerAnalytics, ): Promise { const contentType = request.headers.get("Content-Type") || "text/plain"; const content = await request.arrayBuffer(); @@ -559,6 +569,16 @@ async function handleUpload( // Generate URL with /e/ prefix for encrypted pastes const pasteUrl = isEncrypted ? `${baseUrl}/e/${id}` : `${baseUrl}/${id}`; + // Track paste creation + if (analytics) { + await analytics.trackPasteCreated(request, { + is_encrypted: isEncrypted, + is_one_time: isOneTime, + content_type: contentType, + size_bytes: content.byteLength, + }); + } + // Return the paste URL - we always use the unprefixed ID in the URL return new Response(pasteUrl, { headers: {