keep discoverds fileds in separate sqlite
This commit is contained in:
@@ -153,6 +153,15 @@ categorical, numeric, and detector fields. Existing profile names, field weights
|
|||||||
and detector threshold settings are preserved, so this is the fast path after
|
and detector threshold settings are preserved, so this is the fast path after
|
||||||
field-alias matching improves or after Graylog starts parsing additional fields.
|
field-alias matching improves or after Graylog starts parsing additional fields.
|
||||||
|
|
||||||
|
Profile discovery is accumulated over monitor cycles. This matters in high-EPS
|
||||||
|
environments where each poll only fetches a raw sample for context while
|
||||||
|
aggregate queries count the full window. Fields seen in earlier samples are kept
|
||||||
|
in the local history database and continue to participate in recommended
|
||||||
|
profiles and shared-field matching even if the current raw sample does not
|
||||||
|
contain them. This lets late-arriving or less frequent fields such as custom
|
||||||
|
`lcs_*` application fields stay visible long enough to be reviewed and appended
|
||||||
|
to an existing profile.
|
||||||
|
|
||||||
Enabled streams are normalized through the same event model. Stream profiles
|
Enabled streams are normalized through the same event model. Stream profiles
|
||||||
define the entity, timestamp, categorical, and numeric fields used for baselines.
|
define the entity, timestamp, categorical, and numeric fields used for baselines.
|
||||||
The dashboard and Ollama then correlate behavior across sources, for example a
|
The dashboard and Ollama then correlate behavior across sources, for example a
|
||||||
|
|||||||
@@ -337,6 +337,8 @@ async function refresh() {
|
|||||||
`Baseline sources ready: ${esc((data.baseline || {}).sources_ready || 0)}`,
|
`Baseline sources ready: ${esc((data.baseline || {}).sources_ready || 0)}`,
|
||||||
`Baseline training days: ${esc((data.baseline || {}).training_days || 0)}`,
|
`Baseline training days: ${esc((data.baseline || {}).training_days || 0)}`,
|
||||||
`Baseline DB size: ${esc(bytes((data.baseline || {}).size_bytes || 0))}`,
|
`Baseline DB size: ${esc(bytes((data.baseline || {}).size_bytes || 0))}`,
|
||||||
|
`Discovery fields recorded: ${esc((data.baseline || {}).discovery_fields_recorded || 0)}`,
|
||||||
|
`Discovery cache events: ${esc((data.baseline || {}).discovery_cache_events || 0)}`,
|
||||||
`MCP status: ${esc(mcp.status || 'unknown')}`,
|
`MCP status: ${esc(mcp.status || 'unknown')}`,
|
||||||
mcp.error ? `<span class="sev-high">MCP error: ${esc(mcp.error)}</span>` : '',
|
mcp.error ? `<span class="sev-high">MCP error: ${esc(mcp.error)}</span>` : '',
|
||||||
`Enabled streams: ${esc(enabledStreams.length)}`,
|
`Enabled streams: ${esc(enabledStreams.length)}`,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import sqlite3
|
|||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .profile_suggestions import IGNORED_DISCOVERY_FIELDS
|
||||||
|
|
||||||
|
|
||||||
class HistoryStore:
|
class HistoryStore:
|
||||||
def __init__(self, path: str) -> None:
|
def __init__(self, path: str) -> None:
|
||||||
@@ -58,3 +60,86 @@ class StatusSnapshotStore:
|
|||||||
if isinstance(payload["status_cache"], dict):
|
if isinstance(payload["status_cache"], dict):
|
||||||
payload["status_cache"]["stored_at"] = int(row[0])
|
payload["status_cache"]["stored_at"] = int(row[0])
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
class FieldDiscoveryStore:
|
||||||
|
def __init__(self, path: str) -> None:
|
||||||
|
self.path = Path(path)
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with sqlite3.connect(self.path) as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""create table if not exists field_discovery (
|
||||||
|
stream_id text not null, stream_name text not null, field text not null,
|
||||||
|
seen_count integer not null, numeric_count integer not null,
|
||||||
|
sample_values text not null, first_seen integer not null, last_seen integer not null,
|
||||||
|
primary key (stream_id, field)
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
|
||||||
|
def ingest(self, events: list[object], *, max_values: int = 20, retention_days: int = 30) -> int:
|
||||||
|
now = int(time.time())
|
||||||
|
pending: dict[tuple[str, str, str], dict[str, object]] = {}
|
||||||
|
for event in events:
|
||||||
|
fields = getattr(event, "fields", {})
|
||||||
|
if not isinstance(fields, dict):
|
||||||
|
continue
|
||||||
|
stream_id = str(fields.get("fgai_stream_id") or fields.get("fgai_stream") or "local")
|
||||||
|
stream_name = str(fields.get("fgai_stream") or fields.get("fgai_stream_name") or stream_id)
|
||||||
|
for field, value in fields.items():
|
||||||
|
if field in IGNORED_DISCOVERY_FIELDS:
|
||||||
|
continue
|
||||||
|
text = str(value).strip()
|
||||||
|
if not text or text.lower() in {"-", "--", "unknown", "n/a", "none", "null", "nil", "undefined", "[]", "{}"}:
|
||||||
|
continue
|
||||||
|
key = (stream_id, stream_name, str(field))
|
||||||
|
row = pending.setdefault(key, {"seen_count": 0, "numeric_count": 0, "sample_values": set()})
|
||||||
|
row["seen_count"] = int(row["seen_count"]) + 1
|
||||||
|
try:
|
||||||
|
float(text)
|
||||||
|
row["numeric_count"] = int(row["numeric_count"]) + 1
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
values = row["sample_values"]
|
||||||
|
if isinstance(values, set) and len(values) < max_values:
|
||||||
|
values.add(text)
|
||||||
|
with sqlite3.connect(self.path) as connection:
|
||||||
|
for (stream_id, stream_name, field), row in pending.items():
|
||||||
|
existing = connection.execute("select sample_values from field_discovery where stream_id=? and field=?", (stream_id, field)).fetchone()
|
||||||
|
values = set(row["sample_values"] if isinstance(row["sample_values"], set) else set())
|
||||||
|
if existing:
|
||||||
|
try:
|
||||||
|
values.update(str(item) for item in json.loads(str(existing[0]))[:max_values])
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
pass
|
||||||
|
connection.execute(
|
||||||
|
"""insert into field_discovery values (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
on conflict(stream_id, field) do update set
|
||||||
|
stream_name=excluded.stream_name,
|
||||||
|
seen_count=field_discovery.seen_count+excluded.seen_count,
|
||||||
|
numeric_count=field_discovery.numeric_count+excluded.numeric_count,
|
||||||
|
sample_values=excluded.sample_values,
|
||||||
|
last_seen=excluded.last_seen""",
|
||||||
|
(stream_id, stream_name, field, int(row["seen_count"]), int(row["numeric_count"]), json.dumps(sorted(values)[:max_values]), now, now),
|
||||||
|
)
|
||||||
|
connection.execute("delete from field_discovery where last_seen < ?", (now - retention_days * 86400,))
|
||||||
|
return len(pending)
|
||||||
|
|
||||||
|
def synthetic_events(self) -> list[object]:
|
||||||
|
from .models import LogEvent
|
||||||
|
|
||||||
|
events: list[LogEvent] = []
|
||||||
|
with sqlite3.connect(self.path) as connection:
|
||||||
|
rows = connection.execute("select stream_id, stream_name, field, seen_count, numeric_count, sample_values from field_discovery").fetchall()
|
||||||
|
for stream_id, stream_name, field, seen_count, numeric_count, sample_values in rows:
|
||||||
|
try:
|
||||||
|
values = [str(item) for item in json.loads(str(sample_values))]
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
values = []
|
||||||
|
if not values:
|
||||||
|
values = ["1" if int(numeric_count or 0) else "observed"]
|
||||||
|
event_count = max(1, min(5, len(values)))
|
||||||
|
for index in range(event_count):
|
||||||
|
value = values[index % len(values)]
|
||||||
|
fields = {"fgai_stream_id": str(stream_id), "fgai_stream": str(stream_name), str(field): value}
|
||||||
|
events.append(LogEvent(raw=json.dumps(fields, sort_keys=True), fields=fields))
|
||||||
|
return events
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from .feedback import FeedbackStore
|
|||||||
from .graylog_aggregate import GraylogAggregateSource
|
from .graylog_aggregate import GraylogAggregateSource
|
||||||
from .graylog_mcp import GraylogMcpClient
|
from .graylog_mcp import GraylogMcpClient
|
||||||
from .graylog_source import GraylogStreamSource
|
from .graylog_source import GraylogStreamSource
|
||||||
from .history import HistoryStore, StatusSnapshotStore
|
from .history import FieldDiscoveryStore, HistoryStore, StatusSnapshotStore
|
||||||
from .incidents import IncidentStore, build_incidents
|
from .incidents import IncidentStore, build_incidents
|
||||||
from .data_quality import assess_data_quality
|
from .data_quality import assess_data_quality
|
||||||
from .llm import ollama_dashboard_assessment, ollama_profile_advice
|
from .llm import ollama_dashboard_assessment, ollama_profile_advice
|
||||||
@@ -262,7 +262,13 @@ def build_status(
|
|||||||
for item in profile_readiness
|
for item in profile_readiness
|
||||||
]
|
]
|
||||||
stream_coverage = _stream_coverage(runtime_values, stream_profiles, mcp_status, profile_readiness, stream_titles)
|
stream_coverage = _stream_coverage(runtime_values, stream_profiles, mcp_status, profile_readiness, stream_titles)
|
||||||
profile_suggestions = suggest_stream_profiles(events, existing_profiles=stream_profiles)
|
discovery_cache_events = []
|
||||||
|
discovered_profile_fields = 0
|
||||||
|
if history_path:
|
||||||
|
discovery_store = FieldDiscoveryStore(history_path)
|
||||||
|
discovered_profile_fields = discovery_store.ingest(events)
|
||||||
|
discovery_cache_events = discovery_store.synthetic_events()
|
||||||
|
profile_suggestions = suggest_stream_profiles([*discovery_cache_events, *events], existing_profiles=stream_profiles)
|
||||||
profile_advisor_status = {"enabled": bool(runtime_values.get("profile_advisor_enabled")), "status": "disabled"}
|
profile_advisor_status = {"enabled": bool(runtime_values.get("profile_advisor_enabled")), "status": "disabled"}
|
||||||
if runtime_values.get("profile_advisor_enabled") and profile_suggestions:
|
if runtime_values.get("profile_advisor_enabled") and profile_suggestions:
|
||||||
try:
|
try:
|
||||||
@@ -329,7 +335,7 @@ def build_status(
|
|||||||
"policy_path": policy_path,
|
"policy_path": policy_path,
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
"anomaly_summary": anomaly_summary(anomalies),
|
"anomaly_summary": anomaly_summary(anomalies),
|
||||||
"baseline": {"enabled": bool(baseline), "sources_ready": len(profiles), "training_days": baseline_training_days, "new_events_recorded": baseline_events, "profile_fields_recorded": profile_baseline_fields, "maintenance": baseline_maintenance, "size_bytes": baseline_maintenance.get("size_bytes", 0) if isinstance(baseline_maintenance, dict) else 0},
|
"baseline": {"enabled": bool(baseline), "sources_ready": len(profiles), "training_days": baseline_training_days, "new_events_recorded": baseline_events, "profile_fields_recorded": profile_baseline_fields, "discovery_fields_recorded": discovered_profile_fields, "discovery_cache_events": len(discovery_cache_events), "maintenance": baseline_maintenance, "size_bytes": baseline_maintenance.get("size_bytes", 0) if isinstance(baseline_maintenance, dict) else 0},
|
||||||
"capabilities": {"threat_intel": threat_intel_status, "graylog_mcp": mcp_status, "profile_advisor": profile_advisor_status},
|
"capabilities": {"threat_intel": threat_intel_status, "graylog_mcp": mcp_status, "profile_advisor": profile_advisor_status},
|
||||||
"configuration": runtime_config,
|
"configuration": runtime_config,
|
||||||
"stream_profiles": [{"stream_id": item.stream_id, "name": _profile_name(item.stream_id, stream_titles, item), "stream_name": _stream_name(item.stream_id, stream_titles, item), "entity_field": item.entity_field, "entity_fields": list(item.entity_fields), "timestamp_field": item.timestamp_field, "categorical_fields": list(item.categorical_fields), "numeric_fields": list(item.numeric_fields), "detectors": item.detectors, "field_weights": item.field_weights} for item in stream_profiles.values()],
|
"stream_profiles": [{"stream_id": item.stream_id, "name": _profile_name(item.stream_id, stream_titles, item), "stream_name": _stream_name(item.stream_id, stream_titles, item), "entity_field": item.entity_field, "entity_fields": list(item.entity_fields), "timestamp_field": item.timestamp_field, "categorical_fields": list(item.categorical_fields), "numeric_fields": list(item.numeric_fields), "detectors": item.detectors, "field_weights": item.field_weights} for item in stream_profiles.values()],
|
||||||
|
|||||||
@@ -163,6 +163,35 @@ class MonitorTests(unittest.TestCase):
|
|||||||
self.assertEqual(status["profile_suggestions"][0]["profile_advisor"]["status"], "heuristic")
|
self.assertEqual(status["profile_suggestions"][0]["profile_advisor"]["status"], "heuristic")
|
||||||
self.assertIn("timeout", status["profile_suggestions"][0]["profile_advisor"]["error"])
|
self.assertIn("timeout", status["profile_suggestions"][0]["profile_advisor"]["error"])
|
||||||
|
|
||||||
|
def test_profile_suggestions_use_cached_discovered_fields(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
history_path = str(Path(tmp) / "history.sqlite3")
|
||||||
|
incidents = str(Path(tmp) / "incidents.json")
|
||||||
|
first = Path(tmp) / "first.log"
|
||||||
|
first.write_text(
|
||||||
|
"\n".join(
|
||||||
|
f"fgai_stream_id=app fgai_stream=App lcs_actor=user{index % 4} lcs_result=r{index % 3} action=ok"
|
||||||
|
for index in range(1, 20)
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
build_status(str(first), history_path=history_path, incident_path=incidents)
|
||||||
|
second = Path(tmp) / "second.log"
|
||||||
|
second.write_text(
|
||||||
|
"\n".join(
|
||||||
|
f"fgai_stream_id=app fgai_stream=App action=ok"
|
||||||
|
for _index in range(1, 5)
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
status = build_status(str(second), history_path=history_path, incident_path=incidents)
|
||||||
|
|
||||||
|
suggestion = status["profile_suggestions"][0]
|
||||||
|
self.assertIn("lcs_actor", suggestion["entity_fields"])
|
||||||
|
self.assertIn("lcs_result", suggestion["categorical_fields"])
|
||||||
|
self.assertGreater(status["baseline"]["discovery_cache_events"], 0)
|
||||||
|
|
||||||
def test_cached_status_with_error_keeps_last_good_dashboard_data(self):
|
def test_cached_status_with_error_keeps_last_good_dashboard_data(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
cache_path = str(Path(tmp) / "status-cache.sqlite3")
|
cache_path = str(Path(tmp) / "status-cache.sqlite3")
|
||||||
|
|||||||
Reference in New Issue
Block a user