@jungjaehoon/mama-server 1.0.0

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,403 @@
1
+ /**
2
+ * Hook Metrics Module
3
+ *
4
+ * Story M2.5: Hook Performance Monitoring & Logging
5
+ *
6
+ * Provides structured metrics logging for MAMA hooks:
7
+ * - Per-hook latency tracking
8
+ * - Decision counts and tier state
9
+ * - Auto-save outcomes (accepted/rejected)
10
+ * - Privacy-aware logging (metadata only, sensitive data redacted)
11
+ * - JSONL format for analysis
12
+ *
13
+ * @module hook-metrics
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const crypto = require('crypto');
19
+ const os = require('os');
20
+ const { info, warn, error: logError } = require('./debug-logger');
21
+
22
+ // Metrics log directory
23
+ const LOG_DIR = path.join(os.homedir(), '.mama', 'logs');
24
+ const METRICS_FILE = path.join(LOG_DIR, 'hook-metrics.jsonl');
25
+
26
+ // Performance targets (from MAMA-PRD.md)
27
+ const PERFORMANCE_TARGETS = {
28
+ maxLatencyMs: 500, // p95 target
29
+ warningLatencyMs: 400 // Warning threshold
30
+ };
31
+
32
+ /**
33
+ * Ensure log directory exists
34
+ */
35
+ function ensureLogDir() {
36
+ try {
37
+ if (!fs.existsSync(LOG_DIR)) {
38
+ fs.mkdirSync(LOG_DIR, { recursive: true });
39
+ }
40
+ } catch (error) {
41
+ warn(`[Metrics] Failed to create log directory: ${error.message}`);
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Hash sensitive data for privacy
47
+ * AC: Privacy - logs redact sensitive reasoning/decision bodies
48
+ *
49
+ * @param {string} text - Text to hash
50
+ * @returns {string} SHA-256 hash
51
+ */
52
+ function hashSensitiveData(text) {
53
+ if (!text) return null;
54
+ return crypto.createHash('sha256').update(text).digest('hex').substring(0, 16);
55
+ }
56
+
57
+ /**
58
+ * Redact sensitive fields from object
59
+ * AC: Privacy - storing only metadata and hashed topics
60
+ *
61
+ * @param {Object} data - Data object
62
+ * @returns {Object} Redacted data
63
+ */
64
+ function redactSensitiveData(data) {
65
+ const redacted = { ...data };
66
+
67
+ // Redact sensitive fields
68
+ if (redacted.decision) {
69
+ redacted.decision_hash = hashSensitiveData(redacted.decision);
70
+ delete redacted.decision;
71
+ }
72
+
73
+ if (redacted.reasoning) {
74
+ redacted.reasoning_hash = hashSensitiveData(redacted.reasoning);
75
+ delete redacted.reasoning;
76
+ }
77
+
78
+ if (redacted.topic) {
79
+ redacted.topic_hash = hashSensitiveData(redacted.topic);
80
+ delete redacted.topic;
81
+ }
82
+
83
+ if (redacted.query) {
84
+ redacted.query_hash = hashSensitiveData(redacted.query);
85
+ delete redacted.query;
86
+ }
87
+
88
+ return redacted;
89
+ }
90
+
91
+ /**
92
+ * Log hook metrics
93
+ * AC: Logging middleware captures per-hook timings, decision counts, tier state, outcomes
94
+ *
95
+ * @param {Object} metrics - Metrics object
96
+ * @param {string} metrics.hookName - Hook name (UserPromptSubmit, PreToolUse, PostToolUse)
97
+ * @param {number} metrics.latencyMs - Hook execution latency
98
+ * @param {number} metrics.decisionCount - Number of decisions returned
99
+ * @param {number} metrics.tier - Current tier (1/2/3)
100
+ * @param {string} metrics.tierReason - Reason for current tier
101
+ * @param {string} metrics.outcome - Outcome (success/timeout/error/rate_limited)
102
+ * @param {Object} metrics.metadata - Additional metadata (optional)
103
+ * @returns {void}
104
+ */
105
+ function logHookMetrics(metrics) {
106
+ try {
107
+ ensureLogDir();
108
+
109
+ const timestamp = new Date().toISOString();
110
+
111
+ // Build metrics entry
112
+ const entry = {
113
+ timestamp,
114
+ hook_name: metrics.hookName,
115
+ latency_ms: metrics.latencyMs,
116
+ decision_count: metrics.decisionCount || 0,
117
+ tier: metrics.tier,
118
+ tier_reason: metrics.tierReason,
119
+ outcome: metrics.outcome,
120
+ performance_target_met: metrics.latencyMs <= PERFORMANCE_TARGETS.maxLatencyMs,
121
+ performance_warning: metrics.latencyMs >= PERFORMANCE_TARGETS.warningLatencyMs
122
+ };
123
+
124
+ // Add optional metadata (redacted)
125
+ if (metrics.metadata) {
126
+ entry.metadata = redactSensitiveData(metrics.metadata);
127
+ }
128
+
129
+ // AC: Alerts/logs clearly show degraded Tier side effects
130
+ if (metrics.tier > 1) {
131
+ entry.degraded_mode = true;
132
+ entry.degraded_features = getDegradedFeatures(metrics.tier);
133
+ }
134
+
135
+ // Write JSONL entry
136
+ const logLine = JSON.stringify(entry) + '\n';
137
+ fs.appendFileSync(METRICS_FILE, logLine, 'utf8');
138
+
139
+ // Log performance warnings
140
+ if (metrics.latencyMs >= PERFORMANCE_TARGETS.warningLatencyMs) {
141
+ warn(`[Metrics] ${metrics.hookName} latency warning: ${metrics.latencyMs}ms (target: ${PERFORMANCE_TARGETS.maxLatencyMs}ms)`);
142
+ }
143
+
144
+ // Log degraded tier
145
+ if (metrics.tier > 1) {
146
+ info(`[Metrics] ${metrics.hookName} running in degraded Tier ${metrics.tier}: ${metrics.tierReason}`);
147
+ }
148
+
149
+ } catch (error) {
150
+ logError(`[Metrics] Failed to log hook metrics: ${error.message}`);
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Get degraded features for tier
156
+ * AC: Alerts/logs clearly show degraded Tier side effects
157
+ *
158
+ * @param {number} tier - Current tier
159
+ * @returns {Array<string>} Degraded features
160
+ */
161
+ function getDegradedFeatures(tier) {
162
+ const features = [];
163
+
164
+ if (tier >= 2) {
165
+ features.push('vector_search_disabled');
166
+ features.push('semantic_similarity_unavailable');
167
+ }
168
+
169
+ if (tier >= 3) {
170
+ features.push('graph_traversal_disabled');
171
+ features.push('keyword_search_disabled');
172
+ features.push('mama_fully_disabled');
173
+ }
174
+
175
+ return features;
176
+ }
177
+
178
+ /**
179
+ * Log auto-save outcome
180
+ * AC: outcomes (accepted/rejected auto-save)
181
+ *
182
+ * @param {string} action - Action taken (accept/modify/dismiss)
183
+ * @param {Object} metadata - Metadata (optional, will be redacted)
184
+ */
185
+ function logAutoSaveOutcome(action, metadata = {}) {
186
+ try {
187
+ ensureLogDir();
188
+
189
+ const timestamp = new Date().toISOString();
190
+
191
+ const entry = {
192
+ timestamp,
193
+ event_type: 'auto_save_outcome',
194
+ action,
195
+ metadata: redactSensitiveData(metadata)
196
+ };
197
+
198
+ const logLine = JSON.stringify(entry) + '\n';
199
+ fs.appendFileSync(METRICS_FILE, logLine, 'utf8');
200
+
201
+ info(`[Metrics] Auto-save outcome: ${action}`);
202
+
203
+ } catch (error) {
204
+ logError(`[Metrics] Failed to log auto-save outcome: ${error.message}`);
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Get metrics summary
210
+ * AC: Metrics can be surfaced via /mama-status or ad-hoc CLI command
211
+ *
212
+ * @param {Object} options - Filter options
213
+ * @param {number} options.limit - Maximum entries to return
214
+ * @param {string} options.hookName - Filter by hook name
215
+ * @param {number} options.tier - Filter by tier
216
+ * @param {string} options.outcome - Filter by outcome
217
+ * @returns {Object} Metrics summary
218
+ */
219
+ function getMetricsSummary(options = {}) {
220
+ try {
221
+ if (!fs.existsSync(METRICS_FILE)) {
222
+ return {
223
+ total_entries: 0,
224
+ entries: [],
225
+ statistics: {}
226
+ };
227
+ }
228
+
229
+ const content = fs.readFileSync(METRICS_FILE, 'utf8');
230
+ let entries = content
231
+ .trim()
232
+ .split('\n')
233
+ .filter(Boolean)
234
+ .map(line => JSON.parse(line));
235
+
236
+ // Apply filters
237
+ if (options.hookName) {
238
+ entries = entries.filter(e => e.hook_name === options.hookName);
239
+ }
240
+
241
+ if (options.tier !== undefined) {
242
+ entries = entries.filter(e => e.tier === options.tier);
243
+ }
244
+
245
+ if (options.outcome) {
246
+ entries = entries.filter(e => e.outcome === options.outcome);
247
+ }
248
+
249
+ // Calculate statistics
250
+ const hookMetrics = entries.filter(e => e.hook_name);
251
+ const statistics = {
252
+ total_hook_calls: hookMetrics.length,
253
+ avg_latency_ms: hookMetrics.length > 0
254
+ ? Math.round(hookMetrics.reduce((sum, e) => sum + e.latency_ms, 0) / hookMetrics.length)
255
+ : 0,
256
+ p95_latency_ms: calculatePercentile(hookMetrics.map(e => e.latency_ms), 0.95),
257
+ p99_latency_ms: calculatePercentile(hookMetrics.map(e => e.latency_ms), 0.99),
258
+ performance_target_met_rate: hookMetrics.length > 0
259
+ ? Math.round((hookMetrics.filter(e => e.performance_target_met).length / hookMetrics.length) * 100)
260
+ : 0,
261
+ tier_distribution: getTierDistribution(hookMetrics),
262
+ outcome_distribution: getOutcomeDistribution(hookMetrics),
263
+ degraded_mode_rate: hookMetrics.length > 0
264
+ ? Math.round((hookMetrics.filter(e => e.degraded_mode).length / hookMetrics.length) * 100)
265
+ : 0
266
+ };
267
+
268
+ // Limit entries
269
+ if (options.limit) {
270
+ entries = entries.slice(-options.limit);
271
+ }
272
+
273
+ return {
274
+ total_entries: entries.length,
275
+ entries: entries.reverse(), // Most recent first
276
+ statistics
277
+ };
278
+
279
+ } catch (error) {
280
+ logError(`[Metrics] Failed to get metrics summary: ${error.message}`);
281
+ return {
282
+ total_entries: 0,
283
+ entries: [],
284
+ statistics: {},
285
+ error: error.message
286
+ };
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Calculate percentile
292
+ *
293
+ * @param {Array<number>} values - Values
294
+ * @param {number} percentile - Percentile (0-1)
295
+ * @returns {number} Percentile value
296
+ */
297
+ function calculatePercentile(values, percentile) {
298
+ if (values.length === 0) return 0;
299
+
300
+ const sorted = [...values].sort((a, b) => a - b);
301
+ const index = Math.ceil(sorted.length * percentile) - 1;
302
+ return sorted[Math.max(0, index)];
303
+ }
304
+
305
+ /**
306
+ * Get tier distribution
307
+ *
308
+ * @param {Array<Object>} entries - Metrics entries
309
+ * @returns {Object} Tier distribution
310
+ */
311
+ function getTierDistribution(entries) {
312
+ const distribution = { tier1: 0, tier2: 0, tier3: 0 };
313
+
314
+ entries.forEach(entry => {
315
+ if (entry.tier === 1) distribution.tier1++;
316
+ else if (entry.tier === 2) distribution.tier2++;
317
+ else if (entry.tier === 3) distribution.tier3++;
318
+ });
319
+
320
+ return distribution;
321
+ }
322
+
323
+ /**
324
+ * Get outcome distribution
325
+ *
326
+ * @param {Array<Object>} entries - Metrics entries
327
+ * @returns {Object} Outcome distribution
328
+ */
329
+ function getOutcomeDistribution(entries) {
330
+ const distribution = {};
331
+
332
+ entries.forEach(entry => {
333
+ const outcome = entry.outcome || 'unknown';
334
+ distribution[outcome] = (distribution[outcome] || 0) + 1;
335
+ });
336
+
337
+ return distribution;
338
+ }
339
+
340
+ /**
341
+ * Clear metrics log
342
+ * For testing purposes
343
+ */
344
+ function clearMetrics() {
345
+ try {
346
+ if (fs.existsSync(METRICS_FILE)) {
347
+ fs.unlinkSync(METRICS_FILE);
348
+ }
349
+ } catch (error) {
350
+ warn(`[Metrics] Failed to clear metrics: ${error.message}`);
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Format metrics for display
356
+ * AC: Metrics can be surfaced via /mama-status
357
+ *
358
+ * @param {Object} summary - Metrics summary
359
+ * @returns {string} Formatted metrics
360
+ */
361
+ function formatMetricsDisplay(summary) {
362
+ const stats = summary.statistics;
363
+
364
+ let output = '\nšŸ“Š MAMA Hook Metrics\n';
365
+ output += '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n';
366
+
367
+ // Statistics
368
+ output += `Total Hook Calls: ${stats.total_hook_calls}\n`;
369
+ output += `Average Latency: ${stats.avg_latency_ms}ms\n`;
370
+ output += `P95 Latency: ${stats.p95_latency_ms}ms\n`;
371
+ output += `P99 Latency: ${stats.p99_latency_ms}ms\n`;
372
+ output += `Performance Target Met: ${stats.performance_target_met_rate}%\n`;
373
+ output += `Degraded Mode Rate: ${stats.degraded_mode_rate}%\n\n`;
374
+
375
+ // Tier distribution
376
+ output += 'Tier Distribution:\n';
377
+ output += ` 🟢 Tier 1: ${stats.tier_distribution.tier1}\n`;
378
+ output += ` 🟔 Tier 2: ${stats.tier_distribution.tier2}\n`;
379
+ output += ` šŸ”“ Tier 3: ${stats.tier_distribution.tier3}\n\n`;
380
+
381
+ // Outcome distribution
382
+ output += 'Outcome Distribution:\n';
383
+ Object.entries(stats.outcome_distribution).forEach(([outcome, count]) => {
384
+ output += ` ${outcome}: ${count}\n`;
385
+ });
386
+
387
+ output += '\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n';
388
+
389
+ return output;
390
+ }
391
+
392
+ module.exports = {
393
+ logHookMetrics,
394
+ logAutoSaveOutcome,
395
+ getMetricsSummary,
396
+ formatMetricsDisplay,
397
+ clearMetrics,
398
+ hashSensitiveData,
399
+ redactSensitiveData,
400
+ getDegradedFeatures,
401
+ PERFORMANCE_TARGETS,
402
+ METRICS_FILE
403
+ };