@jhits/plugin-telemetry 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@jhits/plugin-telemetry",
3
+ "version": "0.0.1",
4
+ "description": "System logging and telemetry utilities for the JHITS ecosystem",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "main": "./src/index.ts",
9
+ "types": "./src/index.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./src/index.ts",
13
+ "default": "./src/index.ts"
14
+ },
15
+ "./server": {
16
+ "types": "./src/server.ts",
17
+ "default": "./src/server.ts"
18
+ },
19
+ "./api/route": {
20
+ "types": "./src/api/route.ts",
21
+ "default": "./src/api/route.ts"
22
+ },
23
+ "./api/handler": {
24
+ "types": "./src/api/handler.ts",
25
+ "default": "./src/api/handler.ts"
26
+ },
27
+ "./utils/logCleaner": {
28
+ "types": "./src/utils/logCleaner.ts",
29
+ "default": "./src/utils/logCleaner.ts"
30
+ }
31
+ },
32
+ "dependencies": {
33
+ "@jhits/plugin-core": "^0.0.1"
34
+ },
35
+ "peerDependencies": {
36
+ "next": ">=15.0.0",
37
+ "react": ">=18.0.0",
38
+ "react-dom": ">=18.0.0"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^20.19.27",
42
+ "@types/react": "^19",
43
+ "@types/react-dom": "^19",
44
+ "next": "16.1.1",
45
+ "react": "19.2.3",
46
+ "react-dom": "19.2.3",
47
+ "typescript": "^5"
48
+ },
49
+ "files": [
50
+ "src",
51
+ "package.json"
52
+ ]
53
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Telemetry Provider
3
+ * React context provider for telemetry functionality
4
+ */
5
+ "use client";
6
+
7
+ import React, { createContext, useContext, ReactNode } from 'react';
8
+ import { TelemetryService as ITelemetryService } from './types';
9
+ import { telemetryService } from './TelemetryService';
10
+
11
+ const TelemetryContext = createContext<ITelemetryService | null>(null);
12
+
13
+ export interface TelemetryProviderProps {
14
+ children: ReactNode;
15
+ }
16
+
17
+ export const TelemetryProvider: React.FC<TelemetryProviderProps> = ({ children }) => {
18
+ return (
19
+ <TelemetryContext.Provider value={telemetryService}>
20
+ {children}
21
+ </TelemetryContext.Provider>
22
+ );
23
+ };
24
+
25
+ export const useTelemetry = (): ITelemetryService => {
26
+ const context = useContext(TelemetryContext);
27
+ if (!context) {
28
+ // Fallback to service directly if context is not available
29
+ return telemetryService;
30
+ }
31
+ return context;
32
+ };
33
+
@@ -0,0 +1,337 @@
1
+ /**
2
+ * Telemetry Service
3
+ * Core service for logging and managing telemetry data
4
+ * Uses backend file-based logging with buffering
5
+ */
6
+ import { TelemetryEntry, TelemetryCategory, TelemetryService as ITelemetryService } from './types';
7
+
8
+ const API_ENDPOINT = '/api/dashboard/telemetry';
9
+ const BUFFER_INTERVAL_MS = 5000; // Send buffered logs every 5 seconds
10
+ const MAX_BUFFER_SIZE = 50; // Max entries to buffer before forcing send
11
+
12
+ class TelemetryServiceClass implements ITelemetryService {
13
+ private buffer: TelemetryEntry[] = [];
14
+ private flushTimer: NodeJS.Timeout | null = null;
15
+ private defaultContext: string = 'system';
16
+
17
+ private generateId(): string {
18
+ return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
19
+ }
20
+
21
+ /**
22
+ * Safe JSON stringify that handles circular references and DOM/React elements
23
+ * Uses WeakSet to track seen objects and returns "[Circular]" for repeated references
24
+ */
25
+ private safeStringify(value: any, space?: number): string {
26
+ const seen = new WeakSet();
27
+ const isDOMNode = (val: any): boolean => {
28
+ return (
29
+ typeof Node !== 'undefined' && val instanceof Node ||
30
+ typeof Element !== 'undefined' && val instanceof Element ||
31
+ (val && typeof val === 'object' && 'nodeType' in val && 'nodeName' in val)
32
+ );
33
+ };
34
+ const isReactElement = (val: any): boolean => {
35
+ return (
36
+ val &&
37
+ typeof val === 'object' &&
38
+ (val.$$typeof === Symbol.for('react.element') ||
39
+ val.$$typeof === Symbol.for('react.portal') ||
40
+ Object.keys(val).some(key => key.startsWith('__react') || key.startsWith('_react')))
41
+ );
42
+ };
43
+ const isEvent = (val: any): boolean => {
44
+ return (
45
+ val &&
46
+ typeof val === 'object' &&
47
+ (val instanceof Event ||
48
+ (typeof Event !== 'undefined' && val.constructor?.name === 'Event') ||
49
+ (val.target && val.currentTarget && val.type))
50
+ );
51
+ };
52
+
53
+ const replacer = (key: string, val: any): any => {
54
+ // Skip React internal keys
55
+ if (key.startsWith('__react') || key.startsWith('_react')) {
56
+ return '[React Internal]';
57
+ }
58
+
59
+ // Handle null/undefined
60
+ if (val === null || val === undefined) {
61
+ return val;
62
+ }
63
+
64
+ // Handle DOM nodes
65
+ if (isDOMNode(val)) {
66
+ return '[DOM Node]';
67
+ }
68
+
69
+ // Handle React elements
70
+ if (isReactElement(val)) {
71
+ return '[React Element]';
72
+ }
73
+
74
+ // Handle Events
75
+ if (isEvent(val)) {
76
+ return `[Event: ${val.type || 'unknown'}]`;
77
+ }
78
+
79
+ // Handle functions
80
+ if (typeof val === 'function') {
81
+ return `[Function: ${val.name || 'anonymous'}]`;
82
+ }
83
+
84
+ // Handle circular references
85
+ if (typeof val === 'object') {
86
+ if (seen.has(val)) {
87
+ return '[Circular]';
88
+ }
89
+ seen.add(val);
90
+ }
91
+
92
+ return val;
93
+ };
94
+
95
+ try {
96
+ return JSON.stringify(value, replacer, space);
97
+ } catch (error) {
98
+ // Fallback if stringify still fails
99
+ return JSON.stringify({
100
+ error: 'Failed to stringify',
101
+ message: error instanceof Error ? error.message : String(error),
102
+ type: typeof value,
103
+ }, null, space);
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Get safe JSON serializer that handles circular references and React Fiber nodes
109
+ */
110
+ /**
111
+ * Send entries to backend API
112
+ * CRITICAL: Uses safe serializer to prevent circular JSON errors
113
+ */
114
+ private async sendToBackend(entries: TelemetryEntry[], useBeacon: boolean = false): Promise<void> {
115
+ if (entries.length === 0) return;
116
+
117
+ try {
118
+ // SAFE SERIALIZER to prevent Circular structure errors
119
+ const getCircularReplacer = () => {
120
+ const seen = new WeakSet();
121
+ return (key: string, value: any) => {
122
+ if (typeof value === "object" && value !== null) {
123
+ if (seen.has(value)) return "[Circular]";
124
+ seen.add(value);
125
+ // Strip out DOM elements and React internals
126
+ if (value instanceof Node) return `[DOM: ${value.nodeName}]`;
127
+ if (key.startsWith('__react')) return "[ReactInternal]";
128
+ }
129
+ return value;
130
+ };
131
+ };
132
+
133
+ const data = JSON.stringify(entries, getCircularReplacer());
134
+
135
+ if (navigator.sendBeacon && useBeacon) {
136
+ const blob = new Blob([data], { type: 'application/json' });
137
+ if (navigator.sendBeacon(API_ENDPOINT, blob)) return;
138
+ }
139
+
140
+ const response = await fetch(API_ENDPOINT, {
141
+ method: 'POST',
142
+ headers: { 'Content-Type': 'application/json' },
143
+ body: data,
144
+ });
145
+
146
+ if (!response.ok) throw new Error(`Status ${response.status}`);
147
+ } catch (error) {
148
+ console.error('[TelemetryService] Serialization or Network failure:', error);
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Schedule a flush of the buffer
154
+ */
155
+ private scheduleFlush(): void {
156
+ // Clear existing timer
157
+ if (this.flushTimer) {
158
+ clearTimeout(this.flushTimer);
159
+ }
160
+
161
+ // Schedule new flush
162
+ this.flushTimer = setTimeout(() => {
163
+ this.flush();
164
+ }, BUFFER_INTERVAL_MS);
165
+ }
166
+
167
+ /**
168
+ * Flush buffer to backend immediately
169
+ */
170
+ async flush(useBeacon: boolean = false): Promise<void> {
171
+ if (this.buffer.length === 0) return;
172
+
173
+ const entriesToSend = [...this.buffer];
174
+ this.buffer = [];
175
+
176
+ if (this.flushTimer) {
177
+ clearTimeout(this.flushTimer);
178
+ this.flushTimer = null;
179
+ }
180
+
181
+ await this.sendToBackend(entriesToSend, useBeacon);
182
+ }
183
+
184
+ log(category: TelemetryCategory, message: string, data?: any, context?: string): void {
185
+ const entry: TelemetryEntry = {
186
+ id: this.generateId(),
187
+ timestamp: Date.now(),
188
+ category,
189
+ message,
190
+ data: data || {},
191
+ context: context || this.defaultContext,
192
+ };
193
+
194
+ // Add to buffer
195
+ this.buffer.push(entry);
196
+
197
+ // Also log to console for immediate visibility (less intrusive, emoji-coded breadcrumbs)
198
+ // Only log in development or if explicitly enabled
199
+ if (process.env.NODE_ENV === 'development' || process.env.TELEMETRY_VERBOSE === 'true') {
200
+ const emoji = category === 'DRAG_DROP' ? '📦' : category === 'STATE' ? '📊' : category === 'ERROR' ? '❌' : '🎨';
201
+ // Use safeStringify for console logs too to prevent circular reference errors
202
+ const safeData = data ? this.safeStringify(data) : '';
203
+ console.log(`${emoji} [${entry.context}] ${message}`, safeData || '');
204
+ }
205
+
206
+ // Send immediately if ERROR, otherwise schedule flush
207
+ if (category === 'ERROR') {
208
+ this.flush();
209
+ } else if (this.buffer.length >= MAX_BUFFER_SIZE) {
210
+ // Force flush if buffer is getting large
211
+ this.flush();
212
+ } else {
213
+ // Schedule regular flush
214
+ this.scheduleFlush();
215
+ }
216
+ }
217
+
218
+ error(message: string, error: Error | unknown, data?: any, context?: string): void {
219
+ const errorObj = error instanceof Error ? error : new Error(String(error));
220
+ const entry: TelemetryEntry = {
221
+ id: this.generateId(),
222
+ timestamp: Date.now(),
223
+ category: 'ERROR',
224
+ message,
225
+ data: {
226
+ ...data,
227
+ errorMessage: errorObj.message,
228
+ errorName: errorObj.name,
229
+ },
230
+ stack: errorObj.stack,
231
+ context: context || this.defaultContext,
232
+ };
233
+
234
+ // Add to buffer
235
+ this.buffer.push(entry);
236
+
237
+ // Also log to console for immediate visibility (errors always shown, but with safe stringify)
238
+ // Use safeStringify to prevent circular reference errors in console
239
+ const safeData = data ? this.safeStringify(data) : '';
240
+ console.error(`❌ [${entry.context}] ${message}`, error instanceof Error ? error.message : String(error), safeData || '');
241
+
242
+ // Errors are sent immediately
243
+ this.flush();
244
+ }
245
+
246
+ setContext(context: string): void {
247
+ this.defaultContext = context;
248
+ }
249
+
250
+ exportLogs(): void {
251
+ // Flush any pending logs first
252
+ this.flush().then(() => {
253
+ // Inform user that logs are on the server
254
+ alert('Telemetry logs are stored on the server at: logs/editor-debug.log\n\nCheck the server logs directory for the file.');
255
+ }).catch(() => {
256
+ alert('Telemetry logs are stored on the server. Check logs/editor-debug.log on the server.');
257
+ });
258
+ }
259
+
260
+ clearLogs(): void {
261
+ // Clear the buffer (logs are already on server, can't clear server logs from client)
262
+ this.buffer = [];
263
+ if (this.flushTimer) {
264
+ clearTimeout(this.flushTimer);
265
+ this.flushTimer = null;
266
+ }
267
+ // Less intrusive console log
268
+ if (process.env.NODE_ENV === 'development' || process.env.TELEMETRY_VERBOSE === 'true') {
269
+ console.log('🧹 Telemetry buffer cleared');
270
+ }
271
+ }
272
+
273
+ getLogs(): TelemetryEntry[] {
274
+ // Return current buffer (server logs are not accessible from client)
275
+ return [...this.buffer];
276
+ }
277
+ }
278
+
279
+ // Initialize global error handlers
280
+ const initializeGlobalErrorHandlers = (service: TelemetryServiceClass): void => {
281
+ // Capture window.onerror
282
+ const originalOnError = window.onerror;
283
+ window.onerror = (message, source, lineno, colno, error) => {
284
+ service.error(
285
+ 'Global Error Handler',
286
+ error || new Error(String(message)),
287
+ {
288
+ message: String(message),
289
+ source: String(source),
290
+ lineno,
291
+ colno,
292
+ }
293
+ );
294
+ if (originalOnError) {
295
+ return originalOnError(message, source, lineno, colno, error);
296
+ }
297
+ return false;
298
+ };
299
+
300
+ // Capture unhandled promise rejections
301
+ window.addEventListener('unhandledrejection', (event) => {
302
+ service.error(
303
+ 'Unhandled Promise Rejection',
304
+ event.reason,
305
+ {
306
+ // Don't include the promise object itself (can cause circular references)
307
+ reason: event.reason instanceof Error ? event.reason.message : String(event.reason),
308
+ }
309
+ );
310
+ });
311
+ };
312
+
313
+ // Initialize unload handler for flushing logs
314
+ const initializeUnloadHandler = (service: TelemetryServiceClass): void => {
315
+ // Flush logs on page unload using sendBeacon
316
+ window.addEventListener('beforeunload', () => {
317
+ service.flush(true); // Use sendBeacon for unload
318
+ });
319
+
320
+ // Also handle visibility change (tab switch, minimize, etc.)
321
+ document.addEventListener('visibilitychange', () => {
322
+ if (document.visibilityState === 'hidden') {
323
+ service.flush(true); // Use sendBeacon when tab becomes hidden
324
+ }
325
+ });
326
+ };
327
+
328
+ // Create singleton instance
329
+ export const telemetryService = new TelemetryServiceClass();
330
+
331
+ // Initialize global error handlers and unload handler
332
+ if (typeof window !== 'undefined') {
333
+ initializeGlobalErrorHandlers(telemetryService);
334
+ initializeUnloadHandler(telemetryService);
335
+ }
336
+
337
+
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Telemetry API Handler
3
+ * Plugin-mounted API handler for telemetry logging
4
+ * Compatible with Next.js API routes and Express
5
+ *
6
+ * IMPORTANT: This file should ONLY be imported in server-side API routes.
7
+ * Do NOT import this in client-side code.
8
+ */
9
+ import { NextRequest, NextResponse } from 'next/server';
10
+ import fs from 'fs/promises';
11
+ import path from 'path';
12
+
13
+ const LOG_DIR = path.join(process.cwd(), 'logs');
14
+ const LOG_FILE = path.join(LOG_DIR, 'jhits-system.log');
15
+ const OLD_LOG_FILE = path.join(LOG_DIR, 'jhits-system.old.log');
16
+ const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10MB
17
+ const MAX_BATCH_SIZE = 100; // Maximum entries per request
18
+ const MAX_ENTRY_SIZE = 100 * 1024; // 100KB per entry
19
+
20
+ interface TelemetryEntry {
21
+ id: string;
22
+ timestamp: number;
23
+ category: string;
24
+ message: string;
25
+ data: any;
26
+ stack?: string;
27
+ context?: string;
28
+ userId?: string;
29
+ sessionId?: string;
30
+ }
31
+
32
+ /**
33
+ * Ensure logs directory exists
34
+ */
35
+ async function ensureLogDir(): Promise<void> {
36
+ try {
37
+ await fs.access(LOG_DIR);
38
+ } catch {
39
+ await fs.mkdir(LOG_DIR, { recursive: true });
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Check log file size and rotate if necessary
45
+ */
46
+ async function rotateLogIfNeeded(): Promise<void> {
47
+ try {
48
+ const stats = await fs.stat(LOG_FILE);
49
+ if (stats.size > MAX_LOG_SIZE) {
50
+ // Rename current log to old log
51
+ try {
52
+ await fs.rename(LOG_FILE, OLD_LOG_FILE);
53
+ } catch (error: any) {
54
+ // If old log exists, remove it first
55
+ if (error.code === 'EEXIST' || error.code === 'ENOENT') {
56
+ try {
57
+ await fs.unlink(OLD_LOG_FILE);
58
+ await fs.rename(LOG_FILE, OLD_LOG_FILE);
59
+ } catch {
60
+ // If still fails, just overwrite
61
+ await fs.copyFile(LOG_FILE, OLD_LOG_FILE);
62
+ await fs.unlink(LOG_FILE);
63
+ }
64
+ } else {
65
+ throw error;
66
+ }
67
+ }
68
+ }
69
+ } catch (error: any) {
70
+ // If file doesn't exist, that's fine - we'll create it
71
+ if (error.code !== 'ENOENT') {
72
+ console.error('[Telemetry Handler] Error checking log size:', error);
73
+ }
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Format log entry as single-line JSON
79
+ */
80
+ function formatLogEntry(entry: TelemetryEntry): string {
81
+ const timestamp = new Date(entry.timestamp).toISOString();
82
+ const context = entry.context || 'unknown';
83
+ const userId = entry.userId || 'anonymous';
84
+ const sessionId = entry.sessionId || 'unknown';
85
+
86
+ // Sanitize data to prevent log injection
87
+ const sanitizedData = typeof entry.data === 'object'
88
+ ? JSON.stringify(entry.data).replace(/\n/g, '\\n').replace(/\r/g, '\\r')
89
+ : String(entry.data);
90
+
91
+ const stackStr = entry.stack ? ` "stack":"${entry.stack.replace(/"/g, '\\"').replace(/\n/g, '\\n')}"` : '';
92
+
93
+ return `[${timestamp}] [${context}] [${userId}] [${sessionId}] [${entry.category}] ${entry.message} ${sanitizedData}${stackStr}\n`;
94
+ }
95
+
96
+ /**
97
+ * Validate and sanitize telemetry entry
98
+ */
99
+ function validateEntry(entry: any): TelemetryEntry | null {
100
+ // Basic validation
101
+ if (!entry || typeof entry !== 'object') {
102
+ return null;
103
+ }
104
+
105
+ // Check required fields
106
+ if (!entry.id || !entry.timestamp || !entry.category || !entry.message) {
107
+ return null;
108
+ }
109
+
110
+ // Size check to prevent log injection attacks
111
+ const entrySize = JSON.stringify(entry).length;
112
+ if (entrySize > MAX_ENTRY_SIZE) {
113
+ console.warn('[Telemetry Handler] Entry too large, skipping:', entry.id);
114
+ return null;
115
+ }
116
+
117
+ // Sanitize string fields
118
+ return {
119
+ id: String(entry.id).substring(0, 100),
120
+ timestamp: Number(entry.timestamp),
121
+ category: String(entry.category).substring(0, 50),
122
+ message: String(entry.message).substring(0, 500),
123
+ data: entry.data || {},
124
+ stack: entry.stack ? String(entry.stack).substring(0, 5000) : undefined,
125
+ context: entry.context ? String(entry.context).substring(0, 100) : undefined,
126
+ userId: entry.userId ? String(entry.userId).substring(0, 100) : undefined,
127
+ sessionId: entry.sessionId ? String(entry.sessionId).substring(0, 100) : undefined,
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Extract user/session info from request headers or cookies
133
+ */
134
+ function extractContext(request: NextRequest): { userId?: string; sessionId?: string } {
135
+ // Try to get user ID from headers (custom header)
136
+ const userId = request.headers.get('x-user-id') ||
137
+ request.headers.get('x-userid') ||
138
+ undefined;
139
+
140
+ // Try to get session ID from headers or cookies
141
+ const sessionId = request.headers.get('x-session-id') ||
142
+ request.headers.get('x-sessionid') ||
143
+ request.cookies.get('sessionId')?.value ||
144
+ request.cookies.get('session')?.value ||
145
+ undefined;
146
+
147
+ return { userId, sessionId };
148
+ }
149
+
150
+ /**
151
+ * Next.js API Route Handler
152
+ */
153
+ export async function POST(request: NextRequest): Promise<NextResponse> {
154
+ try {
155
+ // Ensure log directory exists
156
+ await ensureLogDir();
157
+
158
+ // Rotate log if needed
159
+ await rotateLogIfNeeded();
160
+
161
+ // Extract context from request
162
+ const { userId, sessionId } = extractContext(request);
163
+
164
+ // Parse request body
165
+ const body = await request.json();
166
+
167
+ // Handle single entry or batch of entries
168
+ const entries = Array.isArray(body) ? body : [body];
169
+
170
+ // Rate limiting: Check batch size
171
+ if (entries.length > MAX_BATCH_SIZE) {
172
+ return NextResponse.json(
173
+ { success: false, error: `Batch size exceeds maximum of ${MAX_BATCH_SIZE}` },
174
+ { status: 400 }
175
+ );
176
+ }
177
+
178
+ // Validate and sanitize entries
179
+ const validEntries: TelemetryEntry[] = [];
180
+ for (const entry of entries) {
181
+ const validated = validateEntry(entry);
182
+ if (validated) {
183
+ // Add context from request if not present in entry
184
+ if (!validated.userId && userId) {
185
+ validated.userId = userId;
186
+ }
187
+ if (!validated.sessionId && sessionId) {
188
+ validated.sessionId = sessionId;
189
+ }
190
+ validEntries.push(validated);
191
+ }
192
+ }
193
+
194
+ if (validEntries.length === 0) {
195
+ return NextResponse.json(
196
+ { success: false, error: 'No valid entries to log' },
197
+ { status: 400 }
198
+ );
199
+ }
200
+
201
+ // Format and append each entry
202
+ const logLines = validEntries.map(formatLogEntry).join('');
203
+ await fs.appendFile(LOG_FILE, logLines, 'utf8');
204
+
205
+ return NextResponse.json({
206
+ success: true,
207
+ logged: validEntries.length,
208
+ rejected: entries.length - validEntries.length
209
+ });
210
+ } catch (error) {
211
+ console.error('[Telemetry Handler] Error writing log:', error);
212
+ return NextResponse.json(
213
+ { success: false, error: 'Failed to write log' },
214
+ { status: 500 }
215
+ );
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Express-compatible handler
221
+ * For use with Express.js or other Node.js frameworks
222
+ */
223
+ export function telemetryHandler(req: any, res: any): void {
224
+ // This is a simplified version for Express
225
+ // In a real Express setup, you'd need to adapt the NextRequest/NextResponse types
226
+ const handler = async () => {
227
+ try {
228
+ await ensureLogDir();
229
+ await rotateLogIfNeeded();
230
+
231
+ const body = req.body;
232
+ const entries = Array.isArray(body) ? body : [body];
233
+
234
+ if (entries.length > MAX_BATCH_SIZE) {
235
+ return res.status(400).json({
236
+ success: false,
237
+ error: `Batch size exceeds maximum of ${MAX_BATCH_SIZE}`
238
+ });
239
+ }
240
+
241
+ const validEntries: TelemetryEntry[] = [];
242
+ for (const entry of entries) {
243
+ const validated = validateEntry(entry);
244
+ if (validated) {
245
+ // Extract from Express request
246
+ validated.userId = validated.userId || req.headers['x-user-id'] || req.user?.id;
247
+ validated.sessionId = validated.sessionId || req.headers['x-session-id'] || req.session?.id;
248
+ validEntries.push(validated);
249
+ }
250
+ }
251
+
252
+ if (validEntries.length === 0) {
253
+ return res.status(400).json({
254
+ success: false,
255
+ error: 'No valid entries to log'
256
+ });
257
+ }
258
+
259
+ const logLines = validEntries.map(formatLogEntry).join('');
260
+ await fs.appendFile(LOG_FILE, logLines, 'utf8');
261
+
262
+ res.json({
263
+ success: true,
264
+ logged: validEntries.length,
265
+ rejected: entries.length - validEntries.length
266
+ });
267
+ } catch (error) {
268
+ console.error('[Telemetry Handler] Error writing log:', error);
269
+ res.status(500).json({ success: false, error: 'Failed to write log' });
270
+ }
271
+ };
272
+
273
+ handler();
274
+ }
275
+
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Telemetry API Route Handler
3
+ * Server-only wrapper for the telemetry handler
4
+ * This file should ONLY be used in Next.js API routes
5
+ */
6
+ import { POST as handlerPOST } from './handler';
7
+
8
+ export { handlerPOST as POST };
9
+
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Plugin Telemetry
3
+ * Standalone telemetry package for JHITS v2
4
+ *
5
+ * NOTE: API handler is NOT exported here to prevent client-side bundling.
6
+ * Import the handler directly from '@jhits/plugin-telemetry/api/handler' in server-side API routes only.
7
+ */
8
+ export { TelemetryProvider, useTelemetry } from './TelemetryProvider';
9
+ export { telemetryService } from './TelemetryService';
10
+ export type { TelemetryEntry, TelemetryCategory, TelemetryService } from './types';
11
+ export { summarizeBlock, summarizeBlocks, createBlockActionPayload } from './utils/logCleaner';
12
+ export type { BlockSummary, MoveBlockData, DeleteBlockData, InsertBlockData } from './utils/logCleaner';
13
+
package/src/server.ts ADDED
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Server-only exports for @jhits/plugin-telemetry
3
+ * These exports should only be used in server-side code (API routes, server components)
4
+ */
5
+
6
+ import { NextRequest, NextResponse } from 'next/server';
7
+ import fs from 'fs/promises';
8
+ import path from 'path';
9
+
10
+ const LOG_DIR = path.join(process.cwd(), 'logs');
11
+ const LOG_FILE = path.join(LOG_DIR, 'jhits-system.log');
12
+ const OLD_LOG_FILE = path.join(LOG_DIR, 'jhits-system.old.log');
13
+ const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10MB
14
+ const MAX_BATCH_SIZE = 100; // Maximum entries per request
15
+ const MAX_ENTRY_SIZE = 100 * 1024; // 100KB per entry
16
+
17
+ interface TelemetryEntry {
18
+ id: string;
19
+ timestamp: number;
20
+ category: string;
21
+ message: string;
22
+ data: any;
23
+ stack?: string;
24
+ context?: string;
25
+ userId?: string;
26
+ sessionId?: string;
27
+ }
28
+
29
+ /**
30
+ * Ensure logs directory exists
31
+ */
32
+ async function ensureLogDir(): Promise<void> {
33
+ try {
34
+ await fs.access(LOG_DIR);
35
+ } catch {
36
+ await fs.mkdir(LOG_DIR, { recursive: true });
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Check log file size and rotate if necessary
42
+ */
43
+ async function rotateLogIfNeeded(): Promise<void> {
44
+ try {
45
+ const stats = await fs.stat(LOG_FILE);
46
+ if (stats.size > MAX_LOG_SIZE) {
47
+ // Rename current log to old log
48
+ try {
49
+ await fs.rename(LOG_FILE, OLD_LOG_FILE);
50
+ } catch (error: any) {
51
+ // If old log exists, remove it first
52
+ if (error.code === 'EEXIST' || error.code === 'ENOENT') {
53
+ try {
54
+ await fs.unlink(OLD_LOG_FILE);
55
+ await fs.rename(LOG_FILE, OLD_LOG_FILE);
56
+ } catch {
57
+ // If still fails, just overwrite
58
+ await fs.copyFile(LOG_FILE, OLD_LOG_FILE);
59
+ await fs.unlink(LOG_FILE);
60
+ }
61
+ } else {
62
+ throw error;
63
+ }
64
+ }
65
+ }
66
+ } catch (error: any) {
67
+ // If file doesn't exist, that's fine - we'll create it
68
+ if (error.code !== 'ENOENT') {
69
+ console.error('[Telemetry Handler] Error checking log size:', error);
70
+ }
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Format log entry as single-line JSON (or pretty-printed in debug mode)
76
+ * CRITICAL: Single-line format for easier grep/tailing, but pretty-printed if debug flag is on
77
+ */
78
+ function formatLogEntry(entry: TelemetryEntry, debug: boolean = false): string {
79
+ const timestamp = new Date(entry.timestamp).toISOString();
80
+ const context = entry.context || 'unknown';
81
+ const userId = entry.userId || 'anonymous';
82
+ const sessionId = entry.sessionId || 'unknown';
83
+
84
+ // Check for debug mode via environment variable
85
+ const isDebugMode = debug || process.env.TELEMETRY_DEBUG === 'true';
86
+
87
+ if (isDebugMode) {
88
+ // Pretty-printed format for debugging
89
+ const logObject = {
90
+ timestamp,
91
+ context,
92
+ userId,
93
+ sessionId,
94
+ category: entry.category,
95
+ message: entry.message,
96
+ data: entry.data,
97
+ ...(entry.stack && { stack: entry.stack })
98
+ };
99
+ return JSON.stringify(logObject, null, 2) + '\n';
100
+ } else {
101
+ // Single-line JSON format for production (easier grep/tailing)
102
+ const sanitizedData = typeof entry.data === 'object'
103
+ ? JSON.stringify(entry.data).replace(/\n/g, '\\n').replace(/\r/g, '\\r')
104
+ : String(entry.data);
105
+
106
+ const stackStr = entry.stack ? ` "stack":"${entry.stack.replace(/"/g, '\\"').replace(/\n/g, '\\n')}"` : '';
107
+
108
+ return `[${timestamp}] [${context}] [${userId}] [${sessionId}] [${entry.category}] ${entry.message} ${sanitizedData}${stackStr}\n`;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Validate and sanitize telemetry entry
114
+ */
115
+ function validateEntry(entry: any): TelemetryEntry | null {
116
+ // Basic validation
117
+ if (!entry || typeof entry !== 'object') {
118
+ return null;
119
+ }
120
+
121
+ // Check required fields
122
+ if (!entry.id || !entry.timestamp || !entry.category || !entry.message) {
123
+ return null;
124
+ }
125
+
126
+ // Size check to prevent log injection attacks
127
+ const entrySize = JSON.stringify(entry).length;
128
+ if (entrySize > MAX_ENTRY_SIZE) {
129
+ console.warn('[Telemetry Handler] Entry too large, skipping:', entry.id);
130
+ return null;
131
+ }
132
+
133
+ // Sanitize string fields
134
+ return {
135
+ id: String(entry.id).substring(0, 100),
136
+ timestamp: Number(entry.timestamp),
137
+ category: String(entry.category).substring(0, 50),
138
+ message: String(entry.message).substring(0, 500),
139
+ data: entry.data || {},
140
+ stack: entry.stack ? String(entry.stack).substring(0, 5000) : undefined,
141
+ context: entry.context ? String(entry.context).substring(0, 100) : undefined,
142
+ userId: entry.userId ? String(entry.userId).substring(0, 100) : undefined,
143
+ sessionId: entry.sessionId ? String(entry.sessionId).substring(0, 100) : undefined,
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Extract user/session info from request headers or cookies
149
+ */
150
+ function extractContext(request: NextRequest): { userId?: string; sessionId?: string } {
151
+ // Try to get user ID from headers (custom header)
152
+ const userId = request.headers.get('x-user-id') ||
153
+ request.headers.get('x-userid') ||
154
+ undefined;
155
+
156
+ // Try to get session ID from headers or cookies
157
+ const sessionId = request.headers.get('x-session-id') ||
158
+ request.headers.get('x-sessionid') ||
159
+ request.cookies.get('sessionId')?.value ||
160
+ request.cookies.get('session')?.value ||
161
+ undefined;
162
+
163
+ return { userId, sessionId };
164
+ }
165
+
166
+ /**
167
+ * Telemetry Handler
168
+ * Server-only function for processing telemetry data
169
+ *
170
+ * @param data - Telemetry entry or array of entries
171
+ * @param context - Optional context with userId and sessionId
172
+ * @returns Result object with success status and logging information
173
+ */
174
+ export async function telemetryHandler(
175
+ data: any,
176
+ context?: { userId?: string; sessionId?: string }
177
+ ): Promise<{ success: boolean; logged?: number; rejected?: number; error?: string }> {
178
+ try {
179
+ // Ensure log directory exists
180
+ await ensureLogDir();
181
+
182
+ // Rotate log if needed
183
+ await rotateLogIfNeeded();
184
+
185
+ // Handle single entry or batch of entries
186
+ const entries = Array.isArray(data) ? data : [data];
187
+
188
+ // Rate limiting: Check batch size
189
+ if (entries.length > MAX_BATCH_SIZE) {
190
+ return {
191
+ success: false,
192
+ error: `Batch size exceeds maximum of ${MAX_BATCH_SIZE}`
193
+ };
194
+ }
195
+
196
+ // Validate and sanitize entries
197
+ const validEntries: TelemetryEntry[] = [];
198
+ for (const entry of entries) {
199
+ const validated = validateEntry(entry);
200
+ if (validated) {
201
+ // Add context if provided
202
+ if (context?.userId && !validated.userId) {
203
+ validated.userId = context.userId;
204
+ }
205
+ if (context?.sessionId && !validated.sessionId) {
206
+ validated.sessionId = context.sessionId;
207
+ }
208
+ validEntries.push(validated);
209
+ }
210
+ }
211
+
212
+ if (validEntries.length === 0) {
213
+ return {
214
+ success: false,
215
+ error: 'No valid entries to log'
216
+ };
217
+ }
218
+
219
+ // Format and append each entry (check for debug mode)
220
+ const debugMode = process.env.TELEMETRY_DEBUG === 'true';
221
+ const logLines = validEntries.map(entry => formatLogEntry(entry, debugMode)).join('');
222
+ await fs.appendFile(LOG_FILE, logLines, 'utf8');
223
+
224
+ return {
225
+ success: true,
226
+ logged: validEntries.length,
227
+ rejected: entries.length - validEntries.length
228
+ };
229
+ } catch (error) {
230
+ console.error('[Telemetry Handler] Error writing log:', error);
231
+ return {
232
+ success: false,
233
+ error: 'Failed to write log',
234
+ };
235
+ }
236
+ }
237
+
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Type declarations for Next.js server types
3
+ * This package is designed to be used in Next.js applications where these types are available
4
+ */
5
+
6
+ declare module 'next/server' {
7
+ export interface NextRequest extends Request {
8
+ cookies: {
9
+ get(name: string): { value: string } | undefined;
10
+ };
11
+ headers: Headers;
12
+ }
13
+
14
+ export class NextResponse extends Response {
15
+ static json(body: any, init?: ResponseInit): NextResponse;
16
+ }
17
+ }
18
+
package/src/types.ts ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Telemetry Types
3
+ * Type definitions for the telemetry system
4
+ */
5
+
6
+ export type TelemetryCategory = 'DRAG_DROP' | 'STATE' | 'ERROR' | 'UI';
7
+
8
+ export interface TelemetryEntry {
9
+ id: string;
10
+ timestamp: number;
11
+ category: TelemetryCategory;
12
+ message: string;
13
+ data: any;
14
+ stack?: string;
15
+ context?: string;
16
+ userId?: string;
17
+ sessionId?: string;
18
+ }
19
+
20
+ export interface TelemetryService {
21
+ log: (category: TelemetryCategory, message: string, data?: any, context?: string) => void;
22
+ error: (message: string, error: Error | unknown, data?: any, context?: string) => void;
23
+ exportLogs: () => void;
24
+ clearLogs: () => void;
25
+ getLogs: () => TelemetryEntry[];
26
+ flush: () => Promise<void>;
27
+ setContext: (context: string) => void;
28
+ }
29
+
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Log Cleaner Utilities
3
+ * Optimizes telemetry payloads by summarizing large data structures
4
+ */
5
+
6
+ // Use a generic interface to avoid circular dependencies
7
+ interface BlockLike {
8
+ id: string | number;
9
+ type?: string;
10
+ nestedBlocks?: BlockLike[];
11
+ }
12
+
13
+ /**
14
+ * Summarized block representation for telemetry
15
+ */
16
+ export interface BlockSummary {
17
+ id: string;
18
+ type: string;
19
+ childCount: number;
20
+ }
21
+
22
+ /**
23
+ * Summarize a single block to reduce log payload size
24
+ * Only includes essential information: id, type, and child count
25
+ */
26
+ export function summarizeBlock(block: BlockLike | { id: string; type: string; nestedBlocks?: any[] }): BlockSummary {
27
+ return {
28
+ id: String(block.id),
29
+ type: String(block.type || 'unknown'),
30
+ childCount: Array.isArray(block.nestedBlocks) ? block.nestedBlocks.length : 0
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Summarize an array of blocks to reduce log payload size
36
+ * Recursively summarizes nested blocks up to a maximum depth
37
+ */
38
+ export function summarizeBlocks(
39
+ blocks: BlockLike[] | undefined,
40
+ maxDepth: number = 2
41
+ ): BlockSummary[] {
42
+ if (!Array.isArray(blocks) || blocks.length === 0) {
43
+ return [];
44
+ }
45
+
46
+ return blocks.map(block => {
47
+ const summary: BlockSummary = {
48
+ id: String(block.id),
49
+ type: String(block.type || 'unknown'),
50
+ childCount: Array.isArray(block.nestedBlocks) ? block.nestedBlocks.length : 0
51
+ };
52
+
53
+ // Optionally include nested summaries if depth allows (for debugging)
54
+ if (maxDepth > 0 && block.nestedBlocks && block.nestedBlocks.length > 0) {
55
+ // Only include nested summaries in debug mode
56
+ // For production, just the childCount is sufficient
57
+ }
58
+
59
+ return summary;
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Extract action-specific data for telemetry
65
+ */
66
+ export interface MoveBlockData {
67
+ blockId: string;
68
+ fromParent: string | null;
69
+ toParent: string | null;
70
+ fromIndex?: number;
71
+ toIndex: number;
72
+ }
73
+
74
+ export interface DeleteBlockData {
75
+ blockId: string;
76
+ blockType: string;
77
+ parentId: string | null;
78
+ }
79
+
80
+ export interface InsertBlockData {
81
+ newBlockType: string;
82
+ parentId: string | null;
83
+ index: number;
84
+ newBlockId?: string;
85
+ }
86
+
87
+ /**
88
+ * Create optimized telemetry payload for block operations
89
+ * Removes full block arrays and only includes essential action data
90
+ */
91
+ export function createBlockActionPayload(
92
+ actionType: 'MOVE_BLOCK' | 'DELETE_BLOCK' | 'INSERT_BLOCK',
93
+ data: MoveBlockData | DeleteBlockData | InsertBlockData,
94
+ options?: {
95
+ includeBlocks?: boolean; // Only for CRITICAL_ERROR
96
+ affectedBlockId?: string;
97
+ }
98
+ ): Record<string, any> {
99
+ const payload: Record<string, any> = {
100
+ actionType,
101
+ ...data
102
+ };
103
+
104
+ // Only include full blocks for critical errors
105
+ if (options?.includeBlocks && options.affectedBlockId) {
106
+ payload.affectedBlockId = options.affectedBlockId;
107
+ }
108
+
109
+ return payload;
110
+ }
111
+