@chappibunny/repolens 0.4.3 → 0.6.2

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.
@@ -0,0 +1,375 @@
1
+ import * as Sentry from "@sentry/node";
2
+ import { readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { dirname, join } from "node:path";
5
+ import { sanitizeSecrets } from "./secrets.js";
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = dirname(__filename);
9
+
10
+ let initialized = false;
11
+ let enabled = false;
12
+
13
+ /**
14
+ * Initialize error tracking (Sentry)
15
+ * Only enabled if REPOLENS_TELEMETRY_ENABLED=true
16
+ */
17
+ export function initTelemetry() {
18
+ // Skip if already initialized
19
+ if (initialized) return;
20
+ initialized = true;
21
+
22
+ // Check if telemetry is enabled (opt-in)
23
+ const telemetryEnabled = process.env.REPOLENS_TELEMETRY_ENABLED === "true";
24
+
25
+ if (!telemetryEnabled) {
26
+ enabled = false;
27
+ return;
28
+ }
29
+
30
+ try {
31
+ // Get version from package.json
32
+ const packageJsonPath = join(__dirname, "../../package.json");
33
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
34
+ const version = packageJson.version || "unknown";
35
+
36
+ Sentry.init({
37
+ dsn: "https://082083dbf5899ed7e65dfd9b8dc72f90@o4511014913703936.ingest.de.sentry.io/4511014919209040", // TODO: Replace with actual DSN
38
+
39
+ // Release tracking
40
+ release: `repolens@${version}`,
41
+
42
+ // Environment
43
+ environment: process.env.NODE_ENV || "production",
44
+
45
+ // Sample rate (10% of errors)
46
+ sampleRate: 0.1,
47
+
48
+ // Only send errors, not all events
49
+ tracesSampleRate: 0,
50
+
51
+ // Privacy: Don't send PII
52
+ beforeSend(event) {
53
+ // Remove potentially sensitive data
54
+ if (event.request) {
55
+ delete event.request.cookies;
56
+ delete event.request.headers;
57
+ }
58
+
59
+ // Remove file paths that might contain usernames
60
+ if (event.exception?.values) {
61
+ event.exception.values = event.exception.values.map(exception => {
62
+ if (exception.stacktrace?.frames) {
63
+ exception.stacktrace.frames = exception.stacktrace.frames.map(frame => {
64
+ if (frame.filename) {
65
+ // Keep only relative paths
66
+ frame.filename = frame.filename.replace(/.*\/RepoLens\//, "");
67
+ }
68
+ return frame;
69
+ });
70
+ }
71
+
72
+ // Sanitize exception messages for secrets
73
+ if (exception.value) {
74
+ exception.value = sanitizeSecrets(exception.value);
75
+ }
76
+
77
+ return exception;
78
+ });
79
+ }
80
+
81
+ // Sanitize event message
82
+ if (event.message) {
83
+ event.message = sanitizeSecrets(event.message);
84
+ }
85
+
86
+ // Sanitize extra context
87
+ if (event.extra) {
88
+ event.extra = JSON.parse(sanitizeSecrets(JSON.stringify(event.extra)));
89
+ }
90
+
91
+ return event;
92
+ }
93
+ });
94
+
95
+ enabled = true;
96
+ } catch (error) {
97
+ // Silently fail if Sentry init fails - don't break the CLI
98
+ console.error("Failed to initialize telemetry:", error.message);
99
+ enabled = false;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Capture an error
105
+ */
106
+ export function captureError(error, context = {}) {
107
+ if (!enabled) return;
108
+
109
+ try {
110
+ Sentry.captureException(error, {
111
+ extra: context,
112
+ tags: {
113
+ command: context.command || "unknown",
114
+ }
115
+ });
116
+ } catch (e) {
117
+ // Silently fail - don't break the CLI if error tracking fails
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Capture a message (non-error event)
123
+ */
124
+ export function captureMessage(message, level = "info", context = {}) {
125
+ if (!enabled) return;
126
+
127
+ try {
128
+ Sentry.captureMessage(message, {
129
+ level,
130
+ extra: context,
131
+ tags: {
132
+ command: context.command || "unknown",
133
+ }
134
+ });
135
+ } catch (e) {
136
+ // Silently fail
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Add context to current scope
142
+ */
143
+ export function setContext(key, data) {
144
+ if (!enabled) return;
145
+
146
+ try {
147
+ Sentry.setContext(key, data);
148
+ } catch (e) {
149
+ // Silently fail
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Flush pending events and close Sentry
155
+ */
156
+ export async function closeTelemetry() {
157
+ if (!enabled) return;
158
+
159
+ try {
160
+ await Sentry.close(2000); // 2 second timeout
161
+ } catch (e) {
162
+ // Silently fail
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Check if telemetry is enabled
168
+ */
169
+ export function isTelemetryEnabled() {
170
+ return enabled;
171
+ }
172
+
173
+ // ============================================================
174
+ // Usage Tracking & Observability
175
+ // ============================================================
176
+
177
+ const performanceTimers = new Map();
178
+
179
+ /**
180
+ * Start a performance timer for an operation
181
+ * @param {string} operation - Operation name (e.g., "scan", "render", "publish")
182
+ * @param {object} metadata - Additional context about the operation
183
+ */
184
+ export function startTimer(operation, metadata = {}) {
185
+ if (!enabled) return;
186
+
187
+ const key = `${operation}_${Date.now()}`;
188
+ performanceTimers.set(key, {
189
+ operation,
190
+ startTime: Date.now(),
191
+ metadata,
192
+ });
193
+
194
+ return key; // Return key so caller can stop this specific timer
195
+ }
196
+
197
+ /**
198
+ * Stop a performance timer and record the metric
199
+ * @param {string} timerKey - Key returned from startTimer()
200
+ */
201
+ export function stopTimer(timerKey) {
202
+ if (!enabled || !timerKey) return;
203
+
204
+ const timer = performanceTimers.get(timerKey);
205
+ if (!timer) return;
206
+
207
+ const duration = Date.now() - timer.startTime;
208
+ performanceTimers.delete(timerKey);
209
+
210
+ // Send performance metric
211
+ try {
212
+ Sentry.metrics.distribution(
213
+ `operation.duration`,
214
+ duration,
215
+ {
216
+ unit: 'millisecond',
217
+ tags: {
218
+ operation: timer.operation,
219
+ ...timer.metadata,
220
+ },
221
+ }
222
+ );
223
+ } catch (e) {
224
+ // Silently fail
225
+ }
226
+
227
+ return duration;
228
+ }
229
+
230
+ /**
231
+ * Track a usage event (command execution)
232
+ * @param {string} command - Command name (init, doctor, migrate, publish)
233
+ * @param {string} status - "success" or "failure"
234
+ * @param {object} metrics - Metrics about the operation
235
+ */
236
+ export function trackUsage(command, status, metrics = {}) {
237
+ if (!enabled) return;
238
+
239
+ try {
240
+ // Anonymize repository info
241
+ const sanitizedMetrics = {
242
+ // Command info
243
+ command,
244
+ status,
245
+
246
+ // Repository metrics (sanitized)
247
+ fileCount: metrics.fileCount || 0,
248
+ moduleCount: metrics.moduleCount || 0,
249
+
250
+ // AI usage
251
+ aiEnabled: Boolean(metrics.aiEnabled),
252
+ aiProvider: metrics.aiProvider || null,
253
+
254
+ // Publishers used
255
+ publishers: metrics.publishers || [],
256
+
257
+ // Performance
258
+ duration: metrics.duration || null,
259
+
260
+ // Environment (no sensitive data)
261
+ nodeVersion: process.version,
262
+ platform: process.platform,
263
+
264
+ // Timestamp
265
+ timestamp: new Date().toISOString(),
266
+ };
267
+
268
+ // Send as custom Sentry event
269
+ Sentry.captureMessage(`Command: ${command}`, {
270
+ level: status === "success" ? "info" : "warning",
271
+ tags: {
272
+ command,
273
+ status,
274
+ aiEnabled: String(sanitizedMetrics.aiEnabled),
275
+ },
276
+ extra: sanitizedMetrics,
277
+ });
278
+
279
+ // Also track as metric for aggregation
280
+ Sentry.metrics.increment('command.executed', 1, {
281
+ tags: {
282
+ command,
283
+ status,
284
+ ai_enabled: String(sanitizedMetrics.aiEnabled),
285
+ platform: process.platform,
286
+ },
287
+ });
288
+
289
+ } catch (e) {
290
+ // Silently fail
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Track scan metrics
296
+ * @param {object} scanResult - Result from scanRepo()
297
+ */
298
+ export function trackScan(scanResult) {
299
+ if (!enabled) return;
300
+
301
+ try {
302
+ const metrics = {
303
+ filesCount: scanResult.filesCount || 0,
304
+ modulesCount: scanResult.modules?.length || 0,
305
+ apiEndpointsCount: scanResult.api?.length || 0,
306
+ pagesCount: scanResult.pages?.length || 0,
307
+ };
308
+
309
+ // Record metrics
310
+ Sentry.metrics.gauge('scan.files', metrics.filesCount);
311
+ Sentry.metrics.gauge('scan.modules', metrics.modulesCount);
312
+ Sentry.metrics.gauge('scan.api_endpoints', metrics.apiEndpointsCount);
313
+ Sentry.metrics.gauge('scan.pages', metrics.pagesCount);
314
+
315
+ } catch (e) {
316
+ // Silently fail
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Track document generation
322
+ * @param {number} documentCount - Number of documents generated
323
+ * @param {boolean} aiEnabled - Whether AI was used
324
+ */
325
+ export function trackDocumentGeneration(documentCount, aiEnabled) {
326
+ if (!enabled) return;
327
+
328
+ try {
329
+ Sentry.metrics.gauge('docs.generated', documentCount, {
330
+ tags: {
331
+ ai_enabled: String(aiEnabled),
332
+ },
333
+ });
334
+ } catch (e) {
335
+ // Silently fail
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Track publishing
341
+ * @param {string[]} publishers - List of publishers used (e.g., ["notion", "markdown"])
342
+ * @param {string} status - "success" or "failure"
343
+ */
344
+ export function trackPublishing(publishers, status) {
345
+ if (!enabled) return;
346
+
347
+ try {
348
+ publishers.forEach(publisher => {
349
+ Sentry.metrics.increment('publish.attempt', 1, {
350
+ tags: {
351
+ publisher,
352
+ status,
353
+ },
354
+ });
355
+ });
356
+ } catch (e) {
357
+ // Silently fail
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Track migration
363
+ * @param {number} migratedCount - Number of workflows migrated
364
+ * @param {number} skippedCount - Number of workflows skipped
365
+ */
366
+ export function trackMigration(migratedCount, skippedCount) {
367
+ if (!enabled) return;
368
+
369
+ try {
370
+ Sentry.metrics.gauge('migration.workflows_migrated', migratedCount);
371
+ Sentry.metrics.gauge('migration.workflows_skipped', skippedCount);
372
+ } catch (e) {
373
+ // Silently fail
374
+ }
375
+ }