@simbimbo/brainstem 0.0.3 → 0.0.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.
@@ -2,18 +2,111 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import sqlite3
5
+ from datetime import datetime, timezone
5
6
  from pathlib import Path
6
7
  from typing import Any, Iterable, List
7
8
 
8
9
  from .models import Candidate, Event, RawInputEnvelope, Signature
10
+ from .config import resolve_default_db_path
9
11
 
10
12
 
11
13
  def default_db_path() -> Path:
12
- return Path('.brainstem-state') / 'brainstem.sqlite3'
14
+ return Path(resolve_default_db_path())
15
+
16
+
17
+ def _snapshot_timestamp() -> tuple[str, str]:
18
+ now = datetime.now(timezone.utc)
19
+ created_at = now.isoformat(timespec="seconds").replace("+00:00", "Z")
20
+ compact_timestamp = now.strftime("%Y%m%dT%H%M%S%fZ")
21
+ return created_at, compact_timestamp
22
+
23
+
24
+ def create_sqlite_snapshot(db_path: str | None = None) -> dict[str, str | int]:
25
+ source_path = Path(db_path) if db_path else default_db_path()
26
+ if not source_path.exists():
27
+ raise FileNotFoundError(f"source database does not exist: {source_path}")
28
+ if not source_path.is_file():
29
+ raise ValueError(f"source path is not a file: {source_path}")
30
+
31
+ created_at, compact_timestamp = _snapshot_timestamp()
32
+ snapshot_name = f"{source_path.stem}.snapshot.{compact_timestamp}{source_path.suffix}"
33
+ snapshot_path = source_path.with_name(snapshot_name)
34
+
35
+ source_connection = sqlite3.connect(str(source_path))
36
+ snapshot_connection = sqlite3.connect(str(snapshot_path))
37
+ try:
38
+ source_connection.backup(snapshot_connection)
39
+ finally:
40
+ snapshot_connection.close()
41
+ source_connection.close()
42
+
43
+ return {
44
+ "source_path": str(source_path),
45
+ "snapshot_path": str(snapshot_path),
46
+ "size": snapshot_path.stat().st_size,
47
+ "created_at": created_at,
48
+ }
13
49
 
14
50
 
15
51
  RAW_ENVELOPE_STATUSES = ("received", "canonicalized", "parse_failed", "unsupported")
16
52
  RAW_ENVELOPE_FAILURE_STATUSES = ("parse_failed", "unsupported")
53
+ MAINTENANCE_TABLES = ("events", "raw_envelopes", "signatures", "candidates")
54
+
55
+
56
+ def _coerce_raw_envelope_id(value: Any) -> int | None:
57
+ if isinstance(value, bool):
58
+ return None
59
+ if isinstance(value, int):
60
+ return value
61
+ if isinstance(value, str):
62
+ value = value.strip()
63
+ if not value.isdigit():
64
+ return None
65
+ return int(value)
66
+ return None
67
+
68
+
69
+ def _coerce_raw_envelope_id_list(raw_value: Any) -> List[int]:
70
+ if raw_value is None:
71
+ return []
72
+ if isinstance(raw_value, list):
73
+ ids = [_coerce_raw_envelope_id(item) for item in raw_value]
74
+ return [item for item in ids if item is not None]
75
+ if isinstance(raw_value, tuple):
76
+ ids = [_coerce_raw_envelope_id(item) for item in raw_value]
77
+ return [item for item in ids if item is not None]
78
+ return []
79
+
80
+
81
+ def extract_source_raw_envelope_ids(metadata_json: str | None) -> List[int]:
82
+ if not metadata_json:
83
+ return []
84
+ try:
85
+ metadata = json.loads(metadata_json)
86
+ except json.JSONDecodeError:
87
+ return []
88
+ if not isinstance(metadata, dict):
89
+ return []
90
+ return _coerce_raw_envelope_id_list(metadata.get("source_raw_envelope_ids"))
91
+
92
+
93
+ def resolve_maintenance_tables(table_names: Iterable[str] | None = None) -> list[str]:
94
+ if table_names is None:
95
+ return list(MAINTENANCE_TABLES)
96
+
97
+ requested: list[str] = []
98
+ for table_name in table_names:
99
+ table_name = str(table_name).strip().lower()
100
+ if not table_name:
101
+ continue
102
+ if table_name == "all":
103
+ return list(MAINTENANCE_TABLES)
104
+ if table_name not in MAINTENANCE_TABLES:
105
+ raise ValueError(f"unsupported table for maintenance clear: {table_name}")
106
+ if table_name not in requested:
107
+ requested.append(table_name)
108
+
109
+ return requested or list(MAINTENANCE_TABLES)
17
110
 
18
111
 
19
112
  def _validate_canonicalization_status(status: str) -> None:
@@ -101,6 +194,35 @@ def init_db(db_path: str | None = None) -> None:
101
194
  conn.close()
102
195
 
103
196
 
197
+ def get_storage_counts(db_path: str | None = None) -> dict[str, int]:
198
+ init_db(db_path)
199
+ conn = connect(db_path)
200
+ try:
201
+ return {
202
+ table: _query_count(conn, f"SELECT COUNT(*) FROM {table}")
203
+ for table in MAINTENANCE_TABLES
204
+ }
205
+ finally:
206
+ conn.close()
207
+
208
+
209
+ def clear_storage_tables(db_path: str | None = None, *, tables: Iterable[str] | None = None) -> dict[str, int]:
210
+ resolved_tables = resolve_maintenance_tables(tables)
211
+ init_db(db_path)
212
+ conn = connect(db_path)
213
+ try:
214
+ counts: dict[str, int] = {}
215
+ for table in resolved_tables:
216
+ count = _query_count(conn, f"SELECT COUNT(*) FROM {table}")
217
+ conn.execute(f"DELETE FROM {table}")
218
+ conn.execute("DELETE FROM sqlite_sequence WHERE name = ?", (table,))
219
+ counts[table] = count
220
+ conn.commit()
221
+ return counts
222
+ finally:
223
+ conn.close()
224
+
225
+
104
226
  def store_raw_envelopes(raw_envelopes: Iterable[RawInputEnvelope], db_path: str | None = None) -> List[int]:
105
227
  conn = connect(db_path)
106
228
  raw_ids: List[int] = []
@@ -176,17 +298,62 @@ def get_raw_envelope_by_id(raw_envelope_id: int, db_path: str | None = None) ->
176
298
  conn.close()
177
299
 
178
300
 
301
+ def get_raw_envelopes_by_ids(
302
+ raw_envelope_ids: Iterable[int | str | object],
303
+ db_path: str | None = None,
304
+ ) -> List[sqlite3.Row]:
305
+ ids = list(dict.fromkeys(_coerce_raw_envelope_id_list(raw_envelope_ids)))
306
+ if not ids:
307
+ return []
308
+
309
+ conn = connect(db_path)
310
+ try:
311
+ placeholders = ",".join(["?"] * len(ids))
312
+ return conn.execute(
313
+ f"SELECT * FROM raw_envelopes WHERE id IN ({placeholders})",
314
+ ids,
315
+ ).fetchall()
316
+ finally:
317
+ conn.close()
318
+
319
+
179
320
  def _recent_raw_envelopes_query(
180
321
  canonicalization_status: str | None,
181
322
  *,
182
323
  failures_only: bool,
183
- ) -> tuple[str, tuple[str, ...], bool]:
324
+ tenant_id: str | None = None,
325
+ source_type: str | None = None,
326
+ source_id: str | None = None,
327
+ source_path: str | None = None,
328
+ ) -> tuple[str, tuple[str, ...]]:
329
+ where_clauses: list[str] = []
330
+ args: list[str] = []
331
+
184
332
  if canonicalization_status is None and failures_only:
185
- return "WHERE canonicalization_status IN (?, ?)", RAW_ENVELOPE_FAILURE_STATUSES, True
186
- if canonicalization_status is None and not failures_only:
187
- return "", (), False
188
- _validate_canonicalization_status(canonicalization_status)
189
- return "WHERE canonicalization_status = ?", (canonicalization_status,), False
333
+ where_clauses.append("canonicalization_status IN (?, ?)")
334
+ args.extend(RAW_ENVELOPE_FAILURE_STATUSES)
335
+ elif canonicalization_status is None and not failures_only:
336
+ pass
337
+ elif canonicalization_status is not None:
338
+ _validate_canonicalization_status(canonicalization_status)
339
+ where_clauses.append("canonicalization_status = ?")
340
+ args.append(canonicalization_status)
341
+
342
+ if tenant_id is not None:
343
+ where_clauses.append("tenant_id = ?")
344
+ args.append(tenant_id)
345
+ if source_type is not None:
346
+ where_clauses.append("source_type = ?")
347
+ args.append(source_type)
348
+ if source_id is not None:
349
+ where_clauses.append("source_id = ?")
350
+ args.append(source_id)
351
+ if source_path is not None:
352
+ where_clauses.append("source_path = ?")
353
+ args.append(source_path)
354
+
355
+ where_clause = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
356
+ return where_clause, tuple(args)
190
357
 
191
358
 
192
359
  def list_recent_raw_envelopes(
@@ -195,10 +362,21 @@ def list_recent_raw_envelopes(
195
362
  limit: int = 20,
196
363
  *,
197
364
  failures_only: bool = False,
365
+ tenant_id: str | None = None,
366
+ source_type: str | None = None,
367
+ source_id: str | None = None,
368
+ source_path: str | None = None,
198
369
  ) -> List[sqlite3.Row]:
199
370
  conn = connect(db_path)
200
371
  try:
201
- where_clause, status_args, _ = _recent_raw_envelopes_query(status, failures_only=failures_only)
372
+ where_clause, status_args = _recent_raw_envelopes_query(
373
+ status,
374
+ failures_only=failures_only,
375
+ tenant_id=tenant_id,
376
+ source_type=source_type,
377
+ source_id=source_id,
378
+ source_path=source_path,
379
+ )
202
380
  prefix = f"{where_clause} " if where_clause else ""
203
381
  rows = conn.execute(
204
382
  f"""
@@ -214,6 +392,51 @@ def list_recent_raw_envelopes(
214
392
  conn.close()
215
393
 
216
394
 
395
+ def list_canonical_events(
396
+ db_path: str | None = None,
397
+ limit: int = 20,
398
+ *,
399
+ tenant_id: str | None = None,
400
+ source_type: str | None = None,
401
+ host: str | None = None,
402
+ service: str | None = None,
403
+ severity: str | None = None,
404
+ ) -> List[sqlite3.Row]:
405
+ conn = connect(db_path)
406
+ try:
407
+ where_clauses = ["canonicalization_status = ?"]
408
+ args: List[str] = ["canonicalized"]
409
+
410
+ if tenant_id is not None:
411
+ where_clauses.append("tenant_id = ?")
412
+ args.append(tenant_id)
413
+ if source_type is not None:
414
+ where_clauses.append("source_type = ?")
415
+ args.append(source_type)
416
+ if host is not None:
417
+ where_clauses.append("host = ?")
418
+ args.append(host)
419
+ if service is not None:
420
+ where_clauses.append("service = ?")
421
+ args.append(service)
422
+ if severity is not None:
423
+ where_clauses.append("severity = ?")
424
+ args.append(severity)
425
+
426
+ where_clause = " WHERE " + " AND ".join(where_clauses)
427
+ return conn.execute(
428
+ f"""
429
+ SELECT * FROM raw_envelopes
430
+ {where_clause}
431
+ ORDER BY id DESC
432
+ LIMIT ?
433
+ """,
434
+ (*args, max(1, limit)),
435
+ ).fetchall()
436
+ finally:
437
+ conn.close()
438
+
439
+
217
440
  def list_recent_failed_raw_envelopes(
218
441
  db_path: str | None = None,
219
442
  *,
@@ -309,6 +532,72 @@ def get_source_dimension_summaries(
309
532
  conn.close()
310
533
 
311
534
 
535
+ def get_source_status_summaries(
536
+ db_path: str | None = None,
537
+ *,
538
+ limit: int = 20,
539
+ tenant_id: str | None = None,
540
+ source_type: str | None = None,
541
+ source_id: str | None = None,
542
+ source_path: str | None = None,
543
+ ) -> List[dict[str, Any]]:
544
+ init_db(db_path)
545
+ conn = connect(db_path)
546
+ try:
547
+ query = """
548
+ SELECT
549
+ tenant_id,
550
+ source_type,
551
+ source_id,
552
+ source_path,
553
+ COUNT(*) AS raw_count,
554
+ SUM(CASE WHEN canonicalization_status = 'canonicalized' THEN 1 ELSE 0 END) AS canonicalized_count,
555
+ SUM(CASE WHEN canonicalization_status = 'parse_failed' THEN 1 ELSE 0 END) AS parse_failed_count,
556
+ SUM(CASE WHEN canonicalization_status = 'unsupported' THEN 1 ELSE 0 END) AS unsupported_count,
557
+ MIN(timestamp) AS first_seen_at,
558
+ MAX(timestamp) AS last_seen_at
559
+ FROM raw_envelopes
560
+ WHERE 1 = 1
561
+ """
562
+ args: list[Any] = []
563
+ if tenant_id is not None:
564
+ query += " AND tenant_id = ?"
565
+ args.append(tenant_id)
566
+ if source_type is not None:
567
+ query += " AND source_type = ?"
568
+ args.append(source_type)
569
+ if source_id is not None:
570
+ query += " AND source_id = ?"
571
+ args.append(source_id)
572
+ if source_path is not None:
573
+ query += " AND source_path = ?"
574
+ args.append(source_path)
575
+
576
+ query += """
577
+ GROUP BY tenant_id, source_type, source_id, source_path
578
+ ORDER BY last_seen_at DESC, raw_count DESC
579
+ LIMIT ?
580
+ """
581
+ args.append(max(1, limit))
582
+ return [
583
+ {
584
+ "tenant_id": row["tenant_id"],
585
+ "source_type": row["source_type"] or "",
586
+ "source_id": row["source_id"] or "",
587
+ "source_path": row["source_path"] or "",
588
+ "raw_count": int(row["raw_count"]),
589
+ "canonicalized_count": int(row["canonicalized_count"] or 0),
590
+ "parse_failed_count": int(row["parse_failed_count"] or 0),
591
+ "unsupported_count": int(row["unsupported_count"] or 0),
592
+ "first_seen_at": row["first_seen_at"],
593
+ "last_seen_at": row["last_seen_at"],
594
+ }
595
+ for row in conn.execute(query, args).fetchall()
596
+ ]
597
+ finally:
598
+ conn.close()
599
+
600
+
312
601
  def _get_source_dimension_summaries_from_conn(
313
602
  conn: sqlite3.Connection,
314
603
  *,
@@ -358,6 +647,27 @@ def store_signatures(signatures: Iterable[Signature], db_path: str | None = None
358
647
  count = 0
359
648
  try:
360
649
  for signature in signatures:
650
+ row = conn.execute(
651
+ "SELECT metadata_json FROM signatures WHERE signature_key = ?",
652
+ (signature.signature_key,),
653
+ ).fetchone()
654
+
655
+ metadata = dict(signature.metadata)
656
+ raw_ids = _coerce_raw_envelope_id_list(metadata.get("source_raw_envelope_ids"))
657
+ if not raw_ids:
658
+ raw_id = _coerce_raw_envelope_id(metadata.get("source_raw_envelope_id"))
659
+ if raw_id is not None:
660
+ raw_ids = [raw_id]
661
+ metadata.pop("source_raw_envelope_id", None)
662
+
663
+ if row is not None:
664
+ existing_metadata = json.loads(row["metadata_json"] or "{}")
665
+ if not isinstance(existing_metadata, dict):
666
+ existing_metadata = {}
667
+ existing_raw_ids = _coerce_raw_envelope_id_list(existing_metadata.get("source_raw_envelope_ids"))
668
+ metadata = dict(existing_metadata) | dict(metadata)
669
+ metadata["source_raw_envelope_ids"] = sorted(set(existing_raw_ids + raw_ids))
670
+
361
671
  conn.execute(
362
672
  '''
363
673
  INSERT INTO signatures (
@@ -373,7 +683,7 @@ def store_signatures(signatures: Iterable[Signature], db_path: str | None = None
373
683
  signature.event_family,
374
684
  signature.normalized_pattern,
375
685
  signature.service,
376
- json.dumps(signature.metadata, ensure_ascii=False),
686
+ json.dumps(metadata, ensure_ascii=False),
377
687
  ),
378
688
  )
379
689
  count += 1
@@ -416,12 +726,79 @@ def store_candidates(candidates: Iterable[Candidate], db_path: str | None = None
416
726
  conn.close()
417
727
 
418
728
 
419
- def list_candidates(db_path: str | None = None, limit: int = 20) -> List[sqlite3.Row]:
729
+ def list_candidates(
730
+ db_path: str | None = None,
731
+ limit: int = 20,
732
+ *,
733
+ candidate_type: str | None = None,
734
+ decision_band: str | None = None,
735
+ min_score_total: float | None = None,
736
+ ) -> List[sqlite3.Row]:
420
737
  conn = connect(db_path)
421
738
  try:
739
+ where_clauses: List[str] = []
740
+ args: List[Any] = []
741
+
742
+ if candidate_type is not None:
743
+ where_clauses.append("candidate_type = ?")
744
+ args.append(candidate_type)
745
+ if decision_band is not None:
746
+ where_clauses.append("decision_band = ?")
747
+ args.append(decision_band)
748
+ if min_score_total is not None:
749
+ where_clauses.append("score_total >= ?")
750
+ args.append(min_score_total)
751
+
752
+ where_clause = ""
753
+ if where_clauses:
754
+ where_clause = " WHERE " + " AND ".join(where_clauses)
755
+
422
756
  rows = conn.execute(
423
- 'SELECT * FROM candidates ORDER BY score_total DESC, id DESC LIMIT ?',
424
- (max(1, limit),),
757
+ f'SELECT * FROM candidates{where_clause} ORDER BY score_total DESC, id DESC LIMIT ?',
758
+ (*args, max(1, limit)),
759
+ ).fetchall()
760
+ return rows
761
+ finally:
762
+ conn.close()
763
+
764
+
765
+ def list_signatures(
766
+ db_path: str | None = None,
767
+ limit: int = 20,
768
+ *,
769
+ event_family: str | None = None,
770
+ service: str | None = None,
771
+ min_occurrence_count: int | None = None,
772
+ ) -> List[sqlite3.Row]:
773
+ conn = connect(db_path)
774
+ try:
775
+ where_clauses: List[str] = []
776
+ args: List[Any] = []
777
+
778
+ if event_family is not None:
779
+ where_clauses.append("event_family = ?")
780
+ args.append(event_family)
781
+ if service is not None:
782
+ where_clauses.append("service = ?")
783
+ args.append(service)
784
+ if min_occurrence_count is not None:
785
+ where_clauses.append("occurrence_count >= ?")
786
+ args.append(min_occurrence_count)
787
+
788
+ where_clause = ""
789
+ if where_clauses:
790
+ where_clause = " WHERE " + " AND ".join(where_clauses)
791
+
792
+ rows = conn.execute(
793
+ f"""
794
+ SELECT
795
+ id, signature_key, event_family, normalized_pattern, service,
796
+ metadata_json, occurrence_count
797
+ FROM signatures{where_clause}
798
+ ORDER BY occurrence_count DESC, id DESC
799
+ LIMIT ?
800
+ """,
801
+ (*args, max(1, limit)),
425
802
  ).fetchall()
426
803
  return rows
427
804
  finally:
package/docs/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # Runtime Examples
2
+
3
+ Use this compact surface for the implemented runtime API, listener, file-ingest, and LogicMonitor-shaped webhook path.
4
+
5
+ ## 0) Shared runtime settings
6
+
7
+ ```bash
8
+ export BRAINSTEM_API_TOKEN=my-local-token # optional: set only if you want auth required
9
+ export BRAINSTEM_DB_PATH=/tmp/brainstem.sqlite3
10
+ ```
11
+
12
+ `BRAINSTEM_API_TOKEN` is optional. If you do not set it, omit all `X-API-Token` headers in the API examples.
13
+
14
+ ## 1) API entry point
15
+
16
+ ```bash
17
+ # Starts the runtime API
18
+ python -m uvicorn brainstem.api:app --host 127.0.0.1 --port 8000
19
+ ```
20
+
21
+ ```bash
22
+ curl -s http://127.0.0.1:8000/healthz
23
+ ```
24
+
25
+ ## 2) UDP listener entry point
26
+
27
+ ```bash
28
+ # Prints canonicalized events for each received datagram
29
+ python -m brainstem.listener --tenant demo-tenant --host 127.0.0.1 --port 5514 --source-path /var/log/syslog
30
+ ```
31
+
32
+ ```bash
33
+ printf 'Mar 22 03:10:00 fw-01 charon: IPsec SA rekey succeeded\n' | nc -u 127.0.0.1 5514
34
+ ```
35
+
36
+ ## 3) API ingest (syslog payload style)
37
+
38
+ ```bash
39
+ curl -s -X POST http://127.0.0.1:8000/ingest/event \
40
+ -H "Content-Type: application/json" \
41
+ -H "X-API-Token: $BRAINSTEM_API_TOKEN" \
42
+ -d '{"tenant_id":"demo-tenant","source_type":"syslog","source_path":"/var/log/syslog","message_raw":"Mar 22 03:11:00 fw-01 charon: child SA rekey started"}'
43
+ ```
44
+
45
+ ## 4) API ingest for file source events
46
+
47
+ ```bash
48
+ curl -s -X POST http://127.0.0.1:8000/ingest/batch \
49
+ -H "Content-Type: application/json" \
50
+ -H "X-API-Token: $BRAINSTEM_API_TOKEN" \
51
+ -d '{"threshold":2,"db_path":"/tmp/brainstem.sqlite3","events":[{"tenant_id":"demo-tenant","source_type":"file","source_path":"/tmp/manual.log","message_raw":"vpn tunnel dropped and recovered"}]}'
52
+ ```
53
+
54
+ ## 5) LogicMonitor-shaped ingest (connector payloads)
55
+
56
+ ```bash
57
+ curl -s -X POST http://127.0.0.1:8000/ingest/logicmonitor \
58
+ -H "Content-Type: application/json" \
59
+ -H "X-API-Token: $BRAINSTEM_API_TOKEN" \
60
+ -d '{"tenant_id":"demo-tenant","threshold":2,"source_path":"/logicmonitor/ingest","db_path":"/tmp/brainstem.sqlite3","events":[{"resource_id":123,"resource_name":"edge-fw-01","message_raw":"VPN tunnel dropped and recovered","severity":"warning","metadata":{"datasource":"IPSec Tunnel"}}]}'
61
+ ```
62
+
63
+ ## 6) Runtime inspection endpoints (same db path)
64
+
65
+ ```bash
66
+ curl -s "http://127.0.0.1:8000/ingest/recent?db_path=/tmp/brainstem.sqlite3&limit=5" \
67
+ -H "X-API-Token: $BRAINSTEM_API_TOKEN"
68
+ curl -s "http://127.0.0.1:8000/candidates?db_path=/tmp/brainstem.sqlite3&limit=5" \
69
+ -H "X-API-Token: $BRAINSTEM_API_TOKEN"
70
+ curl -s "http://127.0.0.1:8000/signatures?db_path=/tmp/brainstem.sqlite3&limit=5" \
71
+ -H "X-API-Token: $BRAINSTEM_API_TOKEN"
72
+ curl -s "http://127.0.0.1:8000/raw_envelopes?db_path=/tmp/brainstem.sqlite3&limit=5" \
73
+ -H "X-API-Token: $BRAINSTEM_API_TOKEN"
74
+ curl -s "http://127.0.0.1:8000/stats?db_path=/tmp/brainstem.sqlite3" \
75
+ -H "X-API-Token: $BRAINSTEM_API_TOKEN"
76
+ curl -s "http://127.0.0.1:8000/failures?db_path=/tmp/brainstem.sqlite3&limit=5" \
77
+ -H "X-API-Token: $BRAINSTEM_API_TOKEN"
78
+ curl -s "http://127.0.0.1:8000/sources?db_path=/tmp/brainstem.sqlite3&limit=5" \
79
+ -H "X-API-Token: $BRAINSTEM_API_TOKEN"
80
+ curl -s "http://127.0.0.1:8000/sources/status?db_path=/tmp/brainstem.sqlite3&limit=5" \
81
+ -H "X-API-Token: $BRAINSTEM_API_TOKEN"
82
+ ```
83
+
84
+ ## 7) Direct file ingest helper path
85
+
86
+ ```bash
87
+ python - <<'PY'
88
+ from brainstem.ingest import run_ingest_file
89
+
90
+ result = run_ingest_file(
91
+ "tests/fixtures/sample_syslog.log",
92
+ tenant_id="demo-tenant",
93
+ threshold=2,
94
+ db_path="/tmp/brainstem.sqlite3",
95
+ )
96
+ print({
97
+ "events": len(result.events),
98
+ "signatures": len(result.signatures),
99
+ "candidates": len(result.candidates),
100
+ "parse_failed": result.parse_failed,
101
+ })
102
+ PY
103
+ ```