@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 +53 -0
- package/src/TelemetryProvider.tsx +33 -0
- package/src/TelemetryService.ts +337 -0
- package/src/api/handler.ts +275 -0
- package/src/api/route.ts +9 -0
- package/src/index.ts +13 -0
- package/src/server.ts +237 -0
- package/src/types/next.d.ts +18 -0
- package/src/types.ts +29 -0
- package/src/utils/logCleaner.ts +111 -0
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
|
+
|
package/src/api/route.ts
ADDED
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
|
+
|