Persist Phase 2 system telemetry

Adds rebootreasonsjson, bootinfojson and defenderjson with their migration in the same change, so the model never references a column the schema has not applied.

rebootreasons is not redundant with the existing pendingreboot column. That column is a single bool OR-ed from three registry signals and cannot say which fired, and the difference matters: a staged file rename means the box is mid-servicing, while a lone servicing key can sit set for weeks on a machine nobody rebooted.

Null means never reported and is deliberately distinct from an empty list, which means checked and nothing pending. Backfilling [] would assert every pre-existing endpoint was confirmed clean.
This commit is contained in:
benjamin.baker
2026-09-05 06:41:20 -04:00
parent f1e8fe6b4a
commit 2c4d8ab4cb
3 changed files with 128 additions and 0 deletions

View File

@@ -0,0 +1,78 @@
"""Add rebootreasonsjson, bootinfojson and defenderjson to endpointstates.
Phase 2 system-side telemetry. All three are collect-and-persist; none is
surfaced in the UI by this change.
rebootreasonsjson is NOT redundant with the existing pendingreboot column. That
column is a single boolean OR-ed from three registry signals, so it cannot say
which one fired, and the difference is operationally significant: a
PendingFileRenameOperations entry means files are staged for replacement on the
next boot, so the machine is mid-servicing, whereas a lone Component Based
Servicing or Windows Update key can sit set for weeks on a box nobody has
rebooted. Same boolean, very different urgency.
bootinfojson exists because uptime lies under fast startup - a machine reports
days of uptime after what the operator experienced as a shutdown, and a "reboot"
that was really a hybrid resume does not clear the driver or kernel state a
support call is usually trying to clear.
defenderjson is stored deliberately unsurfaced. Defender is known to produce
false positives in this environment, so wiring it to a card now would train
operators to ignore that card; persisting it starts the history accumulating for
whenever someone is ready to interpret it.
JSON rather than scalars: reboot reasons is a variable-length list, and the boot
and Defender payloads are small related groups that are read together and would
otherwise be six sparse columns.
MEDIUMTEXT for consistency with the other snapshot columns on this table.
Nullable with no backfill. Null means never reported, which is deliberately
distinct from an empty list (checked, nothing pending) - defaulting the reboot
reasons to [] would assert every pre-existing endpoint was confirmed clean.
Idempotent: each column-add checks the live schema first, so a re-run and a
db.create_all() site both stay clean on strict MySQL 8.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'endpoints0015systemtelemetry'
down_revision = 'endpoints0014diskhealth'
branch_labels = None
depends_on = None
TABLE_NAME = 'endpointstates'
_COLUMNS = (
'rebootreasonsjson',
'bootinfojson',
'defenderjson',
)
def _has_column(table_name, column_name):
from sqlalchemy import inspect
inspector = inspect(op.get_bind())
if not inspector.has_table(table_name):
return False
return any(col['name'] == column_name
for col in inspector.get_columns(table_name))
def upgrade():
for column_name in _COLUMNS:
if not _has_column(TABLE_NAME, column_name):
op.add_column(
TABLE_NAME,
sa.Column(column_name, sa.Text(length=16777215), nullable=True))
def downgrade():
for column_name in reversed(_COLUMNS):
if _has_column(TABLE_NAME, column_name):
op.drop_column(TABLE_NAME, column_name)

View File

@@ -108,6 +108,27 @@ class EndpointState(db.Model):
# dying. Null = never reported.
diskhealthjson = db.Column(db.Text(length=16777215), nullable=True)
# Which signal(s) drove pendingreboot. That column is a single bool OR-ed
# from three sources and so cannot say WHY, and the difference matters: a
# pendingfilerename means files are staged for replacement on next boot, so
# the box is mid-servicing, while a lone servicing key can sit set for weeks
# on a machine nobody rebooted. Empty list = checked, nothing pending.
# Null = never reported or the check failed.
rebootreasonsjson = db.Column(db.Text(length=16777215), nullable=True)
# Fast startup configuration and what the last boot actually did. Kept
# because uptime is not trustworthy under fast startup: a box can report
# days of uptime after what the operator experienced as a shutdown, and a
# "reboot" that was a hybrid resume does not clear the state a support call
# is usually trying to clear.
bootinfojson = db.Column(db.Text(length=16777215), nullable=True)
# Defender health. Persisted but deliberately NOT surfaced anywhere yet:
# this environment produces known false positives, and wiring it to a card
# now would train operators to ignore that card. Stored so the history is
# already accumulating when someone is ready to interpret it.
defenderjson = db.Column(db.Text(length=16777215), nullable=True)
# Warn-only reconciler output, refreshed on every agent check-in in the same
# commit as servicesjson. Each entry names a policy/shipped-attached monitor
# the endpoint is expected to be running but the latest snapshot does not

View File

@@ -54,6 +54,7 @@ KNOWN_PAYLOAD_KEYS = frozenset({
'scheduledtasks', 'applications', 'events',
'disks', 'bios', 'memory', 'processor', 'osinfo', 'domain',
'pendingreboot', 'usercontext', 'diskhealth',
'rebootreasons', 'bootinfo', 'defender',
})
@@ -790,6 +791,28 @@ class EndpointsPlugin(BasePlugin):
'precomputed predictfailure; null there '
'means the drive did not answer.',
},
'rebootreasons': {
'type': 'array',
'description': 'Which signal(s) drove pendingreboot: '
'componentbasedservicing, windowsupdate, '
'pendingfilerename. Empty array means '
'checked and nothing pending; omit when '
'the check failed.',
},
'bootinfo': {
'type': 'object',
'description': 'hiberbootenabled (fast startup is '
'configured) and lastboottype (what the '
'last boot actually did). Uptime is not '
'trustworthy when fast startup is in '
'play.',
},
'defender': {
'type': 'object',
'description': 'Defender service/realtime state and '
'signature and scan ages in days. '
'Persisted but not surfaced.',
},
'usercontext': {
'type': 'object',
'description': 'Session-scoped data collected by a probe '
@@ -1035,6 +1058,12 @@ class EndpointsPlugin(BasePlugin):
state.pendingreboot = payload.get('pendingreboot')
if 'diskhealth' in payload:
state.diskhealthjson = _as_json(payload.get('diskhealth'))
if 'rebootreasons' in payload:
state.rebootreasonsjson = _as_json(payload.get('rebootreasons'))
if 'bootinfo' in payload:
state.bootinfojson = _as_json(payload.get('bootinfo'))
if 'defender' in payload:
state.defenderjson = _as_json(payload.get('defender'))
# Session-scoped probe output. The agent repeats its last result between
# probe runs and omits the key entirely when nobody is signed in, so the