@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.
@@ -0,0 +1,181 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import socket
6
+ from dataclasses import asdict
7
+ from typing import Any, Callable, List, Optional
8
+
9
+ from .ingest import IngestionResult, run_ingest_source_payload
10
+ from .ingest import canonicalize_raw_input_envelope
11
+ from .config import get_runtime_config
12
+ from .source_drivers import parse_source_payloads
13
+ from .models import CanonicalEvent
14
+ from .models import RawInputEnvelope
15
+
16
+ EventHandler = Callable[[CanonicalEvent], None]
17
+ ErrorHandler = Callable[[Exception, str], None]
18
+ LISTENER_CONFIG = get_runtime_config().listener
19
+
20
+
21
+ def parse_syslog_datagram(
22
+ payload: bytes,
23
+ *,
24
+ tenant_id: str,
25
+ source_path: str = LISTENER_CONFIG.syslog_source_path,
26
+ on_parse_error: Optional[ErrorHandler] = None,
27
+ ) -> List[CanonicalEvent]:
28
+ raw_events = parse_source_payloads(
29
+ "syslog",
30
+ payload,
31
+ tenant_id=tenant_id,
32
+ source_path=source_path,
33
+ on_parse_error=on_parse_error,
34
+ )
35
+ events: List[CanonicalEvent] = []
36
+ for raw_event in raw_events:
37
+ try:
38
+ events.append(canonicalize_raw_input_envelope(raw_event))
39
+ except Exception as exc:
40
+ if on_parse_error is None:
41
+ raise
42
+ on_parse_error(exc, raw_event.metadata.get("raw_line", raw_event.message_raw))
43
+ return events
44
+
45
+
46
+ def parse_syslog_raw_datagram(
47
+ payload: bytes,
48
+ *,
49
+ tenant_id: str,
50
+ source_path: str = LISTENER_CONFIG.syslog_source_path,
51
+ on_parse_error: Optional[ErrorHandler] = None,
52
+ ) -> List[RawInputEnvelope]:
53
+ return parse_source_payloads(
54
+ "syslog",
55
+ payload,
56
+ tenant_id=tenant_id,
57
+ source_path=source_path,
58
+ on_parse_error=on_parse_error,
59
+ )
60
+
61
+
62
+ def run_ingest_syslog_datagram(
63
+ payload: bytes,
64
+ *,
65
+ tenant_id: str,
66
+ source_path: str = LISTENER_CONFIG.syslog_source_path,
67
+ threshold: int | None = None,
68
+ db_path: Optional[str] = None,
69
+ on_event: Optional[EventHandler] = None,
70
+ on_parse_error: Optional[ErrorHandler] = None,
71
+ ) -> IngestionResult:
72
+ if threshold is None:
73
+ threshold = get_runtime_config().listener.ingest_threshold
74
+
75
+ return run_ingest_source_payload(
76
+ "syslog",
77
+ payload,
78
+ tenant_id=tenant_id,
79
+ source_path=source_path,
80
+ threshold=threshold,
81
+ db_path=db_path,
82
+ on_event=on_event,
83
+ on_parse_error=on_parse_error,
84
+ )
85
+
86
+
87
+ def run_udp_syslog_listener(
88
+ tenant_id: str,
89
+ *,
90
+ host: str = LISTENER_CONFIG.syslog_host,
91
+ port: int = LISTENER_CONFIG.syslog_port,
92
+ source_path: Optional[str] = None,
93
+ on_event: Optional[EventHandler] = None,
94
+ on_parse_error: Optional[ErrorHandler] = None,
95
+ db_path: Optional[str] = None,
96
+ threshold: int | None = None,
97
+ max_datagrams: Optional[int] = None,
98
+ socket_timeout: float = LISTENER_CONFIG.syslog_socket_timeout,
99
+ socket_obj: Optional[socket.socket] = None,
100
+ ) -> int:
101
+ if threshold is None:
102
+ threshold = get_runtime_config().listener.ingest_threshold
103
+
104
+ sock = socket_obj
105
+ owns_socket = sock is None
106
+ if sock is None:
107
+ sock = socket.socket(family=socket.AF_INET, type=socket.SOCK_DGRAM)
108
+ try:
109
+ bound_host, bound_port = sock.getsockname()
110
+ if bound_port == 0:
111
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
112
+ sock.bind((host, port))
113
+ bound_host, bound_port = sock.getsockname()
114
+ if source_path is None:
115
+ source_path = f"udp://{bound_host}:{bound_port}"
116
+
117
+ received_events = 0
118
+ sock.settimeout(socket_timeout)
119
+
120
+ processed_datagrams = 0
121
+ while True:
122
+ if max_datagrams is not None and processed_datagrams >= max_datagrams:
123
+ break
124
+ try:
125
+ payload, _ = sock.recvfrom(65535)
126
+ except socket.timeout:
127
+ continue
128
+ processed_datagrams += 1
129
+ if db_path is None:
130
+ events = parse_syslog_datagram(
131
+ payload,
132
+ tenant_id=tenant_id,
133
+ source_path=source_path,
134
+ on_parse_error=on_parse_error,
135
+ )
136
+ else:
137
+ result = run_ingest_syslog_datagram(
138
+ payload,
139
+ tenant_id=tenant_id,
140
+ source_path=source_path,
141
+ threshold=threshold,
142
+ db_path=db_path,
143
+ on_event=None,
144
+ on_parse_error=on_parse_error,
145
+ )
146
+ events = result.events
147
+ for event in events:
148
+ received_events += 1
149
+ if on_event is not None:
150
+ on_event(event)
151
+ return received_events
152
+ finally:
153
+ if owns_socket:
154
+ sock.close()
155
+
156
+
157
+ def main(argv: Optional[list[str]] = None) -> int:
158
+ parser = argparse.ArgumentParser(description="Run the brAInstem UDP syslog listener.")
159
+ parser.add_argument("--host", default=LISTENER_CONFIG.syslog_host, help="Listen host")
160
+ parser.add_argument("--port", type=int, default=LISTENER_CONFIG.syslog_port, help="UDP port")
161
+ parser.add_argument("--tenant", default="demo-tenant", help="Tenant identifier")
162
+ parser.add_argument("--source-path", default="", help="Override source_path metadata on parsed envelopes")
163
+ args = parser.parse_args(argv)
164
+
165
+ source_path = args.source_path or f"udp://{args.host}:{args.port}"
166
+
167
+ def _emit(event: CanonicalEvent) -> None:
168
+ print(json.dumps(asdict(event), default=str))
169
+
170
+ run_udp_syslog_listener(
171
+ args.tenant,
172
+ host=args.host,
173
+ port=args.port,
174
+ source_path=source_path,
175
+ on_event=_emit,
176
+ )
177
+ return 0
178
+
179
+
180
+ if __name__ == "__main__":
181
+ raise SystemExit(main())
@@ -29,6 +29,7 @@ class CanonicalEvent:
29
29
  source_type: str
30
30
  timestamp: str
31
31
  message_raw: str
32
+ raw_envelope_id: int | None = None
32
33
  host: str = ""
33
34
  service: str = ""
34
35
  severity: str = "info"
@@ -5,13 +5,17 @@ from dataclasses import asdict
5
5
  from typing import Iterable, List
6
6
 
7
7
  from .models import Candidate, Event, Signature
8
+ from .interesting import _attention_explanation
8
9
  from .scoring import score_candidate
10
+ from .config import get_runtime_config
9
11
 
10
12
 
11
13
  FAMILY_TITLES = {
12
14
  "failure": "Recurring failure pattern",
13
15
  "auth": "Recurring authentication failure pattern",
14
16
  "service_lifecycle": "Recurring service lifecycle instability",
17
+ "connectivity": "Recurring network connectivity pattern",
18
+ "resource": "Recurring resource pressure pattern",
15
19
  "generic": "Recurring operational pattern",
16
20
  }
17
21
 
@@ -44,20 +48,65 @@ def signature_counts(signatures: Iterable[Signature]) -> Counter:
44
48
  return Counter(sig.signature_key for sig in signatures)
45
49
 
46
50
 
47
- def build_recurrence_candidates(events: List[Event], signatures: List[Signature], *, threshold: int = 2) -> List[Candidate]:
51
+ def _coerce_raw_envelope_id(value: object) -> int | None:
52
+ if isinstance(value, bool):
53
+ return None
54
+ if isinstance(value, int):
55
+ return value
56
+ if isinstance(value, str):
57
+ value = value.strip()
58
+ if not value.isdigit():
59
+ return None
60
+ return int(value)
61
+ return None
62
+
63
+
64
+ def _signature_lineage_index(
65
+ events: List[Event],
66
+ signatures: List[Signature],
67
+ ) -> dict[str, list[int]]:
68
+ per_signature_raw_ids: dict[str, list[int]] = {}
69
+ seen = {}
70
+ for event, signature in zip(events, signatures):
71
+ raw_envelope_id = _coerce_raw_envelope_id(getattr(event, "raw_envelope_id", None))
72
+ if raw_envelope_id is None:
73
+ continue
74
+ seen.setdefault(signature.signature_key, set()).add(raw_envelope_id)
75
+
76
+ for key, values in seen.items():
77
+ per_signature_raw_ids[key] = sorted(values)
78
+ return per_signature_raw_ids
79
+
80
+
81
+ def build_recurrence_candidates(
82
+ events: List[Event],
83
+ signatures: List[Signature],
84
+ *,
85
+ threshold: int | None = None,
86
+ ) -> List[Candidate]:
87
+ runtime_config = get_runtime_config()
88
+ profile = runtime_config.candidate_attention
89
+ if threshold is None:
90
+ threshold = runtime_config.defaults.recurrence_threshold
91
+
48
92
  counts = signature_counts(signatures)
49
93
  candidates: List[Candidate] = []
94
+ signature_raw_envelope_ids = _signature_lineage_index(events, signatures)
50
95
  for signature in signatures:
51
96
  count = counts[signature.signature_key]
52
97
  if count < threshold:
53
98
  continue
54
- recurrence = min(count / 10.0, 1.0)
55
- recovery = 0.4
56
- spread = 0.2
57
- novelty = 0.3
58
- impact = 0.5 if signature.event_family in {"failure", "auth"} else 0.2
59
- precursor = 0.3
60
- memory_weight = 0.4
99
+ recurrence = min(count / float(profile.recurrence_count_normalizer), 1.0)
100
+ recovery = profile.recovery_signal_weight
101
+ spread = profile.spread_signal_weight
102
+ novelty = profile.novelty_signal_weight
103
+ impact = (
104
+ profile.impact_high_weight
105
+ if signature.event_family in {"failure", "auth"}
106
+ else profile.impact_default_weight
107
+ )
108
+ precursor = profile.precursor_weight
109
+ memory_weight = profile.memory_weight
61
110
  candidate = score_candidate(
62
111
  recurrence=recurrence,
63
112
  recovery=recovery,
@@ -71,7 +120,11 @@ def build_recurrence_candidates(events: List[Event], signatures: List[Signature]
71
120
  candidate.summary = _candidate_summary(signature, count)
72
121
  candidate.source_signature_ids = [signature.signature_key]
73
122
  candidate.source_event_ids = [str(i) for i, sig in enumerate(signatures) if sig.signature_key == signature.signature_key]
74
- candidate.metadata = {"count": count, "service": signature.service}
123
+ candidate.metadata = {
124
+ "count": count,
125
+ "service": signature.service,
126
+ "source_raw_envelope_ids": signature_raw_envelope_ids.get(signature.signature_key, []),
127
+ }
75
128
  candidates.append(candidate)
76
129
  # dedupe by signature key/title
77
130
  seen = set()
@@ -105,6 +158,7 @@ def digest_items(candidates: Iterable[Candidate]) -> List[dict]:
105
158
  "attention_band": _attention_band(c.decision_band),
106
159
  "attention_score": c.score_total,
107
160
  "score_total": c.score_total,
161
+ "attention_explanation": _attention_explanation(c),
108
162
  "score_breakdown": c.score_breakdown,
109
163
  "metadata": c.metadata,
110
164
  }
@@ -1,16 +1,18 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from .models import Candidate
4
+ from .config import get_runtime_config
4
5
 
5
6
 
6
7
  def decision_band(score_total: float) -> str:
7
- if score_total >= 0.85:
8
+ profile = get_runtime_config().candidate_attention
9
+ if score_total >= profile.decision_band_promote:
8
10
  return "promote_to_incident_memory"
9
- if score_total >= 0.65:
11
+ if score_total >= profile.decision_band_urgent_human_review:
10
12
  return "urgent_human_review"
11
- if score_total >= 0.45:
13
+ if score_total >= profile.decision_band_review:
12
14
  return "review"
13
- if score_total >= 0.25:
15
+ if score_total >= profile.decision_band_watch:
14
16
  return "watch"
15
17
  return "ignore"
16
18
 
@@ -0,0 +1,179 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Callable, Iterable, List, Protocol, runtime_checkable
5
+
6
+ from .adapters import get_raw_input_adapter
7
+ from .models import RawInputEnvelope
8
+
9
+ # Register LogicMonitor adapter definitions for structured ingest support.
10
+ from .connectors import logicmonitor # noqa: F401
11
+
12
+ ErrorHandler = Callable[[Exception, str], None]
13
+
14
+
15
+ def iter_syslog_lines(payload: bytes | str) -> list[str]:
16
+ text = payload.decode("utf-8", errors="replace") if isinstance(payload, (bytes, bytearray)) else str(payload)
17
+ return [line.rstrip("\r") for line in text.splitlines() if line.strip()]
18
+
19
+
20
+ @runtime_checkable
21
+ class SourceDriver(Protocol):
22
+ """Contract for a small source-to-raw-envelope driver."""
23
+
24
+ source_type: str
25
+
26
+ def parse_payload(
27
+ self,
28
+ payload: Any,
29
+ *,
30
+ tenant_id: str,
31
+ source_path: str = "",
32
+ on_parse_error: ErrorHandler | None = None,
33
+ ) -> List[RawInputEnvelope]:
34
+ """Parse one input payload into one or more raw envelopes."""
35
+
36
+
37
+ _SOURCE_DRIVER_REGISTRY: dict[str, SourceDriver] = {}
38
+
39
+
40
+ def register_source_driver(driver: SourceDriver) -> None:
41
+ _SOURCE_DRIVER_REGISTRY[driver.source_type] = driver
42
+
43
+
44
+ def get_source_driver(source_type: str) -> SourceDriver:
45
+ try:
46
+ return _SOURCE_DRIVER_REGISTRY[source_type]
47
+ except KeyError as exc:
48
+ raise ValueError(f"unsupported source_type: {source_type}") from exc
49
+
50
+
51
+ def list_source_driver_types() -> list[str]:
52
+ return sorted(_SOURCE_DRIVER_REGISTRY.keys())
53
+
54
+
55
+ def _iter_payload_items(payload: Any) -> Iterable[Any]:
56
+ if payload is None or isinstance(payload, (bytes, bytearray, str)):
57
+ yield payload
58
+ return
59
+ try:
60
+ iter(payload)
61
+ except TypeError:
62
+ yield payload
63
+ return
64
+ yield from payload
65
+
66
+
67
+ def parse_source_payloads(
68
+ source_type: str,
69
+ payloads: Any,
70
+ *,
71
+ tenant_id: str,
72
+ source_path: str = "",
73
+ on_parse_error: ErrorHandler | None = None,
74
+ ) -> List[RawInputEnvelope]:
75
+ driver = get_source_driver(source_type)
76
+ raw_envelopes: List[RawInputEnvelope] = []
77
+ for payload in _iter_payload_items(payloads):
78
+ raw_envelopes.extend(
79
+ driver.parse_payload(
80
+ payload,
81
+ tenant_id=tenant_id,
82
+ source_path=source_path,
83
+ on_parse_error=on_parse_error,
84
+ )
85
+ )
86
+ return raw_envelopes
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class FileSourceDriver:
91
+ """Driver for file-line payloads."""
92
+
93
+ source_type: str = "file"
94
+
95
+ def parse_payload(
96
+ self,
97
+ payload: Any,
98
+ *,
99
+ tenant_id: str,
100
+ source_path: str = "",
101
+ on_parse_error: ErrorHandler | None = None,
102
+ ) -> List[RawInputEnvelope]:
103
+ adapter = get_raw_input_adapter(self.source_type)
104
+ text = "" if payload is None else str(payload)
105
+ try:
106
+ return [adapter.parse_raw_input(text, tenant_id=tenant_id, source_path=source_path)]
107
+ except Exception as exc:
108
+ if on_parse_error is None:
109
+ raise
110
+ on_parse_error(exc, text)
111
+ return []
112
+
113
+
114
+ @dataclass(frozen=True)
115
+ class SyslogSourceDriver:
116
+ """Driver for syslog payload chunks and line-oriented payloads."""
117
+
118
+ source_type: str = "syslog"
119
+
120
+ def parse_payload(
121
+ self,
122
+ payload: Any,
123
+ *,
124
+ tenant_id: str,
125
+ source_path: str = "",
126
+ on_parse_error: ErrorHandler | None = None,
127
+ ) -> List[RawInputEnvelope]:
128
+ adapter = get_raw_input_adapter(self.source_type)
129
+ raw_envelopes: List[RawInputEnvelope] = []
130
+
131
+ if isinstance(payload, (bytes, bytearray)):
132
+ payload_lines = iter_syslog_lines(payload)
133
+ for line in payload_lines:
134
+ try:
135
+ raw_envelopes.append(adapter.parse_raw_input(line, tenant_id=tenant_id, source_path=source_path))
136
+ except Exception as exc:
137
+ if on_parse_error is None:
138
+ raise
139
+ on_parse_error(exc, line)
140
+ return raw_envelopes
141
+
142
+ payload_text = "" if payload is None else str(payload)
143
+ try:
144
+ raw_envelopes.append(adapter.parse_raw_input(payload_text, tenant_id=tenant_id, source_path=source_path))
145
+ except Exception as exc:
146
+ if on_parse_error is None:
147
+ raise
148
+ on_parse_error(exc, payload_text)
149
+ return raw_envelopes
150
+
151
+
152
+ @dataclass(frozen=True)
153
+ class LogicMonitorSourceDriver:
154
+ """Driver for narrow LogicMonitor-shaped payloads."""
155
+
156
+ source_type: str = "logicmonitor"
157
+
158
+ def parse_payload(
159
+ self,
160
+ payload: Any,
161
+ *,
162
+ tenant_id: str,
163
+ source_path: str = "",
164
+ on_parse_error: ErrorHandler | None = None,
165
+ ) -> List[RawInputEnvelope]:
166
+ adapter = get_raw_input_adapter(self.source_type)
167
+ payload_text = "" if payload is None else str(payload)
168
+ try:
169
+ return [adapter.parse_raw_input(payload, tenant_id=tenant_id, source_path=source_path)]
170
+ except Exception as exc:
171
+ if on_parse_error is None:
172
+ raise
173
+ on_parse_error(exc, payload_text)
174
+ return []
175
+
176
+
177
+ register_source_driver(FileSourceDriver())
178
+ register_source_driver(SyslogSourceDriver())
179
+ register_source_driver(LogicMonitorSourceDriver())