@cybermem/cli 0.6.1 → 0.6.5

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.
@@ -6,14 +6,14 @@ Queries OpenMemory's database and exports per-client metrics to Prometheus.
6
6
  Replaces the complex Vector + Traefik access logs pipeline with simple DB queries.
7
7
  """
8
8
 
9
+ import logging
9
10
  import os
10
- import time
11
11
  import sqlite3
12
- import json
13
- from prometheus_client import Gauge, Info, generate_latest, CONTENT_TYPE_LATEST
14
- from flask import Flask, Response, request, jsonify
15
- import logging
16
12
  import threading
13
+ import time
14
+
15
+ from flask import Flask, Response, jsonify, request
16
+ from prometheus_client import CONTENT_TYPE_LATEST, Gauge, Info, generate_latest
17
17
 
18
18
  # Configuration
19
19
  DB_PATH = os.getenv("DB_PATH", "/data/openmemory.sqlite")
@@ -22,71 +22,59 @@ EXPORTER_PORT = int(os.getenv("EXPORTER_PORT", "8000"))
22
22
 
23
23
  # Setup logging
24
24
  logging.basicConfig(
25
- level=logging.INFO,
26
- format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
25
+ level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
27
26
  )
28
27
  logger = logging.getLogger("db_exporter")
29
28
 
30
29
  # Prometheus metrics
31
- info = Info('cybermem_exporter', 'CyberMem Database Exporter Info')
32
- info.info({'version': '1.0.0', 'db_path': DB_PATH})
30
+ info = Info("cybermem_exporter", "CyberMem Database Exporter Info")
31
+ info.info({"version": "1.0.0", "db_path": DB_PATH})
33
32
 
34
33
  memories_total = Gauge(
35
- 'openmemory_memories_total',
36
- 'Total number of memories stored',
37
- ['client']
34
+ "openmemory_memories_total", "Total number of memories stored", ["client"]
38
35
  )
39
36
 
40
37
  memories_recent_24h = Gauge(
41
- 'openmemory_memories_recent_24h',
42
- 'Memories created in the last 24 hours',
43
- ['client']
38
+ "openmemory_memories_recent_24h",
39
+ "Memories created in the last 24 hours",
40
+ ["client"],
44
41
  )
45
42
 
46
43
  memories_recent_1h = Gauge(
47
- 'openmemory_memories_recent_1h',
48
- 'Memories created in the last hour',
49
- ['client']
44
+ "openmemory_memories_recent_1h", "Memories created in the last hour", ["client"]
50
45
  )
51
46
 
52
47
  requests_by_operation = Gauge(
53
- 'openmemory_requests_total',
54
- 'Total requests by client and operation (from cybermem_stats table)',
55
- ['client_name', 'operation']
48
+ "openmemory_requests_total",
49
+ "Total requests by client and operation (from cybermem_stats table)",
50
+ ["client_name", "operation"],
56
51
  )
57
52
 
58
53
  errors_by_operation = Gauge(
59
- 'openmemory_errors_total',
60
- 'Total errors by client and operation (from cybermem_stats table)',
61
- ['client_name', 'operation']
54
+ "openmemory_errors_total",
55
+ "Total errors by client and operation (from cybermem_stats table)",
56
+ ["client_name", "operation"],
62
57
  )
63
58
 
64
59
  sectors_count = Gauge(
65
- 'openmemory_sectors_total',
66
- 'Number of unique sectors per client',
67
- ['client']
60
+ "openmemory_sectors_total", "Number of unique sectors per client", ["client"]
68
61
  )
69
62
 
70
- avg_score = Gauge(
71
- 'openmemory_avg_score',
72
- 'Average score of memories',
73
- ['client']
74
- )
63
+ avg_score = Gauge("openmemory_avg_score", "Average score of memories", ["client"])
75
64
 
76
65
  # Aggregate metrics (not per-client)
77
66
  total_requests_aggregate = Gauge(
78
- 'openmemory_requests_aggregate_total',
79
- 'Total API requests (aggregate, from stats table)'
67
+ "openmemory_requests_aggregate_total",
68
+ "Total API requests (aggregate, from stats table)",
80
69
  )
81
70
 
82
71
  total_errors_aggregate = Gauge(
83
- 'openmemory_errors_aggregate_total',
84
- 'Total API errors (aggregate, from stats table)'
72
+ "openmemory_errors_aggregate_total",
73
+ "Total API errors (aggregate, from stats table)",
85
74
  )
86
75
 
87
76
  success_rate_aggregate = Gauge(
88
- 'openmemory_success_rate_aggregate',
89
- 'API success rate percentage (aggregate)'
77
+ "openmemory_success_rate_aggregate", "API success rate percentage (aggregate)"
90
78
  )
91
79
 
92
80
 
@@ -108,79 +96,97 @@ def collect_metrics():
108
96
  cursor = db.cursor()
109
97
 
110
98
  # Metric 1: Total memories per client
111
- cursor.execute('''
99
+ cursor.execute("""
112
100
  SELECT user_id as client, COUNT(*) as count
113
101
  FROM memories
114
102
  GROUP BY user_id
115
- ''')
103
+ """)
116
104
  for row in cursor.fetchall():
117
- client = row['client'] or 'anonymous'
118
- memories_total.labels(client=client).set(row['count'])
105
+ client = row["client"] or "anonymous"
106
+ memories_total.labels(client=client).set(row["count"])
119
107
 
120
108
  logger.debug(f"Collected total memories for {cursor.rowcount} clients")
121
109
 
122
110
  # Metric 2: Recent memories (24h)
123
111
  # Note: created_at is stored as milliseconds since epoch
124
- cursor.execute('''
112
+ cursor.execute(
113
+ """
125
114
  SELECT user_id as client, COUNT(*) as count
126
115
  FROM memories
127
116
  WHERE created_at > ?
128
117
  GROUP BY user_id
129
- ''', [int((time.time() - 86400) * 1000)])
118
+ """,
119
+ [int((time.time() - 86400) * 1000)],
120
+ )
130
121
  for row in cursor.fetchall():
131
- client = row['client'] or 'anonymous'
132
- memories_recent_24h.labels(client=client).set(row['count'])
122
+ client = row["client"] or "anonymous"
123
+ memories_recent_24h.labels(client=client).set(row["count"])
133
124
 
134
125
  logger.debug(f"Collected 24h memories for {cursor.rowcount} clients")
135
126
 
136
127
  # Metric 3: Recent memories (1h)
137
- cursor.execute('''
128
+ cursor.execute(
129
+ """
138
130
  SELECT user_id as client, COUNT(*) as count
139
131
  FROM memories
140
132
  WHERE created_at > ?
141
133
  GROUP BY user_id
142
- ''', [int((time.time() - 3600) * 1000)])
134
+ """,
135
+ [int((time.time() - 3600) * 1000)],
136
+ )
143
137
  for row in cursor.fetchall():
144
- client = row['client'] or 'anonymous'
145
- memories_recent_1h.labels(client=client).set(row['count'])
138
+ client = row["client"] or "anonymous"
139
+ memories_recent_1h.labels(client=client).set(row["count"])
146
140
 
147
141
  logger.debug(f"Collected 1h memories for {cursor.rowcount} clients")
148
142
 
149
143
  # Metric 4: Per-client request stats from cybermem_stats table
150
- cursor.execute('''
144
+ cursor.execute("""
151
145
  SELECT client_name, operation, count, errors
152
146
  FROM cybermem_stats
153
- ''')
147
+ """)
154
148
  for row in cursor.fetchall():
155
- client_name = row['client_name'] or 'unknown'
156
- operation = row['operation']
157
- count = row['count']
158
- errors = row['errors']
159
- requests_by_operation.labels(client_name=client_name, operation=operation).set(count)
160
- errors_by_operation.labels(client_name=client_name, operation=operation).set(errors)
161
-
162
- logger.debug(f"Collected request stats for {cursor.rowcount} client/operation pairs")
149
+ client_name = row["client_name"] or "unknown"
150
+ operation = row["operation"]
151
+ count = row["count"]
152
+ errors = row["errors"]
153
+ requests_by_operation.labels(
154
+ client_name=client_name, operation=operation
155
+ ).set(count)
156
+ errors_by_operation.labels(
157
+ client_name=client_name, operation=operation
158
+ ).set(errors)
159
+
160
+ logger.debug(
161
+ f"Collected request stats for {cursor.rowcount} client/operation pairs"
162
+ )
163
163
 
164
164
  # Metric 5: Aggregate request stats from OpenMemory's stats table
165
165
  # Note: stats table has no client_id, so these are aggregate only
166
166
  hour_ago_ms = int((time.time() - 3600) * 1000)
167
167
 
168
168
  # Get total requests (sum of qps snapshots)
169
- cursor.execute('''
169
+ cursor.execute(
170
+ """
170
171
  SELECT SUM(count) as total
171
172
  FROM stats
172
173
  WHERE type = 'qps' AND ts > ?
173
- ''', [hour_ago_ms])
174
- total_reqs = cursor.fetchone()['total'] or 0
174
+ """,
175
+ [hour_ago_ms],
176
+ )
177
+ total_reqs = cursor.fetchone()["total"] or 0
175
178
  total_requests_aggregate.set(total_reqs)
176
179
 
177
180
  # Get total errors
178
- cursor.execute('''
181
+ cursor.execute(
182
+ """
179
183
  SELECT COUNT(*) as total
180
184
  FROM stats
181
185
  WHERE type = 'error' AND ts > ?
182
- ''', [hour_ago_ms])
183
- total_errs = cursor.fetchone()['total'] or 0
186
+ """,
187
+ [hour_ago_ms],
188
+ )
189
+ total_errs = cursor.fetchone()["total"] or 0
184
190
  total_errors_aggregate.set(total_errs)
185
191
 
186
192
  # Calculate success rate
@@ -193,28 +199,28 @@ def collect_metrics():
193
199
  logger.debug(f"Collected aggregate stats: {total_reqs} reqs, {total_errs} errs")
194
200
 
195
201
  # Metric 5: Number of unique sectors per client
196
- cursor.execute('''
202
+ cursor.execute("""
197
203
  SELECT user_id as client, COUNT(DISTINCT primary_sector) as count
198
204
  FROM memories
199
205
  WHERE primary_sector IS NOT NULL
200
206
  GROUP BY user_id
201
- ''')
207
+ """)
202
208
  for row in cursor.fetchall():
203
- client = row['client'] or 'anonymous'
204
- sectors_count.labels(client=client).set(row['count'])
209
+ client = row["client"] or "anonymous"
210
+ sectors_count.labels(client=client).set(row["count"])
205
211
 
206
212
  logger.debug(f"Collected sectors count for {cursor.rowcount} clients")
207
213
 
208
214
  # Metric 6: Average feedback score per client
209
- cursor.execute('''
215
+ cursor.execute("""
210
216
  SELECT user_id as client, AVG(feedback_score) as avg_score
211
217
  FROM memories
212
218
  WHERE feedback_score IS NOT NULL
213
219
  GROUP BY user_id
214
- ''')
220
+ """)
215
221
  for row in cursor.fetchall():
216
- client = row['client'] or 'anonymous'
217
- avg_score.labels(client=client).set(row['avg_score'] or 0)
222
+ client = row["client"] or "anonymous"
223
+ avg_score.labels(client=client).set(row["avg_score"] or 0)
218
224
 
219
225
  logger.debug(f"Collected average scores for {cursor.rowcount} clients")
220
226
 
@@ -231,26 +237,31 @@ def get_logs_from_db(start_ms: int, limit: int = 100):
231
237
  db = get_db_connection()
232
238
  cursor = db.cursor()
233
239
 
234
- cursor.execute('''
240
+ cursor.execute(
241
+ """
235
242
  SELECT timestamp, client_name, client_version, method, endpoint, operation, status, is_error
236
243
  FROM cybermem_access_log
237
244
  WHERE timestamp >= ?
238
245
  ORDER BY timestamp DESC
239
246
  LIMIT ?
240
- ''', [start_ms, limit])
247
+ """,
248
+ [start_ms, limit],
249
+ )
241
250
 
242
251
  logs = []
243
252
  for row in cursor.fetchall():
244
- logs.append({
245
- 'timestamp': row['timestamp'],
246
- 'client_name': row['client_name'],
247
- 'client_version': row['client_version'],
248
- 'method': row['method'],
249
- 'endpoint': row['endpoint'],
250
- 'operation': row['operation'],
251
- 'status': row['status'],
252
- 'is_error': bool(row['is_error'])
253
- })
253
+ logs.append(
254
+ {
255
+ "timestamp": row["timestamp"],
256
+ "client_name": row["client_name"],
257
+ "client_version": row["client_version"],
258
+ "method": row["method"],
259
+ "endpoint": row["endpoint"],
260
+ "operation": row["operation"],
261
+ "status": row["status"],
262
+ "is_error": bool(row["is_error"]),
263
+ }
264
+ )
254
265
 
255
266
  db.close()
256
267
  return logs
@@ -262,23 +273,220 @@ def get_logs_from_db(start_ms: int, limit: int = 100):
262
273
  # Create Flask app
263
274
  app = Flask(__name__)
264
275
 
265
- @app.route('/metrics')
276
+
277
+ @app.route("/metrics")
266
278
  def metrics():
267
279
  """Prometheus metrics endpoint"""
268
280
  return Response(generate_latest(), mimetype=CONTENT_TYPE_LATEST)
269
281
 
270
- @app.route('/api/logs')
282
+
283
+ @app.route("/api/logs")
271
284
  def api_logs():
272
285
  """Access logs API endpoint"""
273
286
  try:
274
- start_ms = int(request.args.get('start', 0))
275
- limit = int(request.args.get('limit', 100))
287
+ start_ms = int(request.args.get("start", 0))
288
+ limit = int(request.args.get("limit", 100))
276
289
 
277
290
  logs = get_logs_from_db(start_ms, limit)
278
- return jsonify({'logs': logs})
291
+ return jsonify({"logs": logs})
279
292
  except Exception as e:
280
293
  logger.error(f"Error in /api/logs: {e}", exc_info=True)
281
- return jsonify({'error': str(e)}), 500
294
+ return jsonify({"error": str(e)}), 500
295
+
296
+
297
+ @app.route("/api/stats")
298
+ def api_stats():
299
+ """Dashboard stats API endpoint - single source of truth for dashboard metrics"""
300
+ try:
301
+ db = get_db_connection()
302
+ cursor = db.cursor()
303
+
304
+ # 1. Total memory records
305
+ cursor.execute("SELECT COUNT(*) as count FROM memories")
306
+ memory_records = cursor.fetchone()["count"] or 0
307
+
308
+ # 2. Unique clients (from memories table)
309
+ cursor.execute("SELECT COUNT(DISTINCT user_id) as count FROM memories")
310
+ total_clients = cursor.fetchone()["count"] or 0
311
+
312
+ # 3. Total requests and errors from cybermem_stats
313
+ cursor.execute(
314
+ "SELECT SUM(count) as total, SUM(errors) as errors FROM cybermem_stats"
315
+ )
316
+ row = cursor.fetchone()
317
+ total_requests = row["total"] or 0
318
+ total_errors = row["errors"] or 0
319
+ success_rate = (
320
+ ((total_requests - total_errors) / total_requests * 100)
321
+ if total_requests > 0
322
+ else 100.0
323
+ )
324
+
325
+ # 4. Top writer (client with most writes)
326
+ cursor.execute("""
327
+ SELECT client_name, SUM(count) as total
328
+ FROM cybermem_stats
329
+ WHERE operation IN ('create', 'update', 'delete')
330
+ GROUP BY client_name
331
+ ORDER BY total DESC
332
+ LIMIT 1
333
+ """)
334
+ top_writer_row = cursor.fetchone()
335
+ top_writer = {
336
+ "name": top_writer_row["client_name"] if top_writer_row else "N/A",
337
+ "count": top_writer_row["total"] if top_writer_row else 0,
338
+ }
339
+
340
+ # 5. Top reader
341
+ cursor.execute("""
342
+ SELECT client_name, SUM(count) as total
343
+ FROM cybermem_stats
344
+ WHERE operation IN ('read', 'list', 'query', 'search', 'other')
345
+ GROUP BY client_name
346
+ ORDER BY total DESC
347
+ LIMIT 1
348
+ """)
349
+ top_reader_row = cursor.fetchone()
350
+ top_reader = {
351
+ "name": top_reader_row["client_name"] if top_reader_row else "N/A",
352
+ "count": top_reader_row["total"] if top_reader_row else 0,
353
+ }
354
+
355
+ # 6. Last writer (most recent write operation from access log)
356
+ cursor.execute("""
357
+ SELECT client_name, timestamp
358
+ FROM cybermem_access_log
359
+ WHERE operation IN ('create', 'update', 'delete')
360
+ ORDER BY timestamp DESC
361
+ LIMIT 1
362
+ """)
363
+ last_writer_row = cursor.fetchone()
364
+ last_writer = {
365
+ "name": last_writer_row["client_name"] if last_writer_row else "N/A",
366
+ "timestamp": last_writer_row["timestamp"] if last_writer_row else 0,
367
+ }
368
+
369
+ # 7. Last reader
370
+ cursor.execute("""
371
+ SELECT client_name, timestamp
372
+ FROM cybermem_access_log
373
+ WHERE operation IN ('read', 'list', 'query', 'search', 'other')
374
+ ORDER BY timestamp DESC
375
+ LIMIT 1
376
+ """)
377
+ last_reader_row = cursor.fetchone()
378
+ last_reader = {
379
+ "name": last_reader_row["client_name"] if last_reader_row else "N/A",
380
+ "timestamp": last_reader_row["timestamp"] if last_reader_row else 0,
381
+ }
382
+
383
+ db.close()
384
+
385
+ return jsonify(
386
+ {
387
+ "memoryRecords": memory_records,
388
+ "totalClients": total_clients,
389
+ "successRate": round(success_rate, 1),
390
+ "totalRequests": total_requests,
391
+ "topWriter": top_writer,
392
+ "topReader": top_reader,
393
+ "lastWriter": last_writer,
394
+ "lastReader": last_reader,
395
+ }
396
+ )
397
+ except Exception as e:
398
+ logger.error(f"Error in /api/stats: {e}", exc_info=True)
399
+ return jsonify({"error": str(e)}), 500
400
+
401
+
402
+ @app.route("/api/timeseries")
403
+ def api_timeseries():
404
+ """Time series data for dashboard charts - bucketed by hour"""
405
+ try:
406
+ period = request.args.get("period", "24h")
407
+
408
+ # Parse period to milliseconds
409
+ period_map = {"1h": 3600, "24h": 86400, "7d": 604800, "30d": 2592000}
410
+ period_seconds = period_map.get(period, 86400)
411
+ start_ms = int((time.time() - period_seconds) * 1000)
412
+
413
+ # Bucket size: 1h for 24h, 6h for 7d, 1d for 30d
414
+ if period in ["1h", "24h"]:
415
+ bucket_format = "%Y-%m-%d %H:00"
416
+ bucket_seconds = 3600
417
+ elif period == "7d":
418
+ bucket_format = "%Y-%m-%d %H:00"
419
+ bucket_seconds = 21600 # 6 hours
420
+ else:
421
+ bucket_format = "%Y-%m-%d"
422
+ bucket_seconds = 86400
423
+
424
+ db = get_db_connection()
425
+ cursor = db.cursor()
426
+
427
+ # Get operations grouped by time bucket and client
428
+ cursor.execute(
429
+ """
430
+ SELECT
431
+ datetime(timestamp/1000, 'unixepoch', 'localtime') as dt,
432
+ client_name,
433
+ operation,
434
+ COUNT(*) as count
435
+ FROM cybermem_access_log
436
+ WHERE timestamp >= ?
437
+ GROUP BY strftime(?, datetime(timestamp/1000, 'unixepoch', 'localtime')), client_name, operation
438
+ ORDER BY dt
439
+ """,
440
+ [start_ms, bucket_format],
441
+ )
442
+
443
+ # Organize by operation type
444
+ creates = {}
445
+ reads = {}
446
+ updates = {}
447
+ deletes = {}
448
+
449
+ for row in cursor.fetchall():
450
+ dt = row["dt"]
451
+ client = row["client_name"] or "unknown"
452
+ op = row["operation"]
453
+ count = row["count"]
454
+
455
+ # Round to bucket
456
+ ts = int(time.mktime(time.strptime(dt, "%Y-%m-%d %H:%M:%S")))
457
+ bucket_ts = (ts // bucket_seconds) * bucket_seconds
458
+
459
+ if op == "create":
460
+ if bucket_ts not in creates:
461
+ creates[bucket_ts] = {"time": bucket_ts}
462
+ creates[bucket_ts][client] = creates[bucket_ts].get(client, 0) + count
463
+ elif op in ["read", "list", "query", "search", "other"]:
464
+ if bucket_ts not in reads:
465
+ reads[bucket_ts] = {"time": bucket_ts}
466
+ reads[bucket_ts][client] = reads[bucket_ts].get(client, 0) + count
467
+ elif op == "update":
468
+ if bucket_ts not in updates:
469
+ updates[bucket_ts] = {"time": bucket_ts}
470
+ updates[bucket_ts][client] = updates[bucket_ts].get(client, 0) + count
471
+ elif op == "delete":
472
+ if bucket_ts not in deletes:
473
+ deletes[bucket_ts] = {"time": bucket_ts}
474
+ deletes[bucket_ts][client] = deletes[bucket_ts].get(client, 0) + count
475
+
476
+ db.close()
477
+
478
+ return jsonify(
479
+ {
480
+ "creates": list(creates.values()),
481
+ "reads": list(reads.values()),
482
+ "updates": list(updates.values()),
483
+ "deletes": list(deletes.values()),
484
+ }
485
+ )
486
+ except Exception as e:
487
+ logger.error(f"Error in /api/timeseries: {e}", exc_info=True)
488
+ return jsonify({"error": str(e)}), 500
489
+
282
490
 
283
491
  def metrics_collection_loop():
284
492
  """Background thread for collecting metrics"""
@@ -291,6 +499,7 @@ def metrics_collection_loop():
291
499
  logger.error(f"Error in metrics collection: {e}", exc_info=True)
292
500
  time.sleep(SCRAPE_INTERVAL)
293
501
 
502
+
294
503
  def main():
295
504
  """Start the exporter and metrics collection loop."""
296
505
  logger.info(f"Starting CyberMem Database Exporter on port {EXPORTER_PORT}")
@@ -306,8 +515,8 @@ def main():
306
515
  logger.info(f" Metrics: http://0.0.0.0:{EXPORTER_PORT}/metrics")
307
516
  logger.info(f" Logs API: http://0.0.0.0:{EXPORTER_PORT}/api/logs")
308
517
 
309
- app.run(host='0.0.0.0', port=EXPORTER_PORT, threaded=True)
518
+ app.run(host="0.0.0.0", port=EXPORTER_PORT, threaded=True)
310
519
 
311
520
 
312
- if __name__ == '__main__':
521
+ if __name__ == "__main__":
313
522
  main()
@@ -0,0 +1,15 @@
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY injector.py .
9
+
10
+ ENV UPSTREAM_URL=http://openmemory:8080
11
+ ENV PORT=8081
12
+
13
+ EXPOSE 8081
14
+
15
+ CMD ["gunicorn", "-b", "0.0.0.0:8081", "-w", "2", "injector:app"]