feat(mqtt): store F-070 crash detail and group crashes fleet-wide

Firmware F-070 adds a fuller `crash` object (backtrace, backtrace_corrupted,
elf_sha256) and a new `pre_crash` snapshot (uptime, heap stats, optional
abort_msg) to boot_report and to telemetry.get_boot_history entries.

- Migration c9d0e1f2a3b4: device_boot_events gains crash / pre_crash JSONB,
  stored verbatim so later additive fields need no migration. The legacy
  crash_* columns are still filled; old rows and old firmware are unchanged.
  JSON is bound as CAST(CAST(:x AS TEXT) AS JSONB): a bare JSONB cast makes
  asyncpg JSON-encode the already-encoded string a second time.
- boot_report ingestion passes both objects through.
- telemetry.get_boot_history replies (control/ack) are merged into boot
  history whoever sent the command: an entry matches an existing row on
  boot_count + reset_reason + time within 30 min (boot_count alone is not
  unique - the counter gets reset), and only fills crash/pre_crash the row
  lacks; unmatched entries are boots we never saw live and are inserted at
  the device's timestamp; entries without ts are skipped.
- GET /api/mqtt/crash-groups: fault boots grouped by abort_msg with hex
  addresses stripped, else task + exception cause, else reset reason. When
  abort_msg is present, pc/exc_cause describe abort() itself and are
  ignored for grouping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 16:28:40 +03:00
co-authored by Claude Opus 5.5
parent 46c5c0a846
commit 963516ece0
7 changed files with 365 additions and 25 deletions
@@ -0,0 +1,32 @@
"""boot event crash detail (firmware F-070)
Firmware F-070 enriches boot_report (and telemetry.get_boot_history entries)
with a fuller `crash` object (backtrace, backtrace_corrupted, elf_sha256) and a
new, independent `pre_crash` object (uptime/heap snapshot + optional
abort_msg). Both are stored verbatim as JSONB so future additive fields don't
need another migration. The existing flat crash_* columns stay and are still
filled, so older rows and older firmware keep working unchanged.
Revision ID: c9d0e1f2a3b4
Revises: b8c9d0e1f2a3
Create Date: 2026-09-30 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "c9d0e1f2a3b4"
down_revision: Union[str, None] = "b8c9d0e1f2a3"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("device_boot_events", sa.Column("crash", postgresql.JSONB(), nullable=True))
op.add_column("device_boot_events", sa.Column("pre_crash", postgresql.JSONB(), nullable=True))
def downgrade() -> None:
op.drop_column("device_boot_events", "pre_crash")
op.drop_column("device_boot_events", "crash")
+4
View File
@@ -23,6 +23,8 @@ from database.pg_mqtt import (
insert_boot_event, insert_boot_event,
get_boot_events, get_boot_events,
get_latest_boot_event, get_latest_boot_event,
merge_device_boot_history,
get_crash_events,
insert_ping_sample, insert_ping_sample,
get_ping_samples, get_ping_samples,
get_latest_ping_samples, get_latest_ping_samples,
@@ -74,6 +76,8 @@ __all__ = [
"insert_boot_event", "insert_boot_event",
"get_boot_events", "get_boot_events",
"get_latest_boot_event", "get_latest_boot_event",
"merge_device_boot_history",
"get_crash_events",
"insert_ping_sample", "insert_ping_sample",
"get_ping_samples", "get_ping_samples",
"get_latest_ping_samples", "get_latest_ping_samples",
+184 -20
View File
@@ -438,13 +438,56 @@ async def get_latest_alert_event(device_serial: str) -> dict | None:
# Health tab's boot timeline + restart chart. # Health tab's boot timeline + restart chart.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_BOOT_COLUMNS = """
id, device_serial, boot_count, reset_reason, is_fault, free_heap,
crash_task, crash_pc, crash_exc_cause, crash_exc_vaddr,
crash::text AS crash, pre_crash::text AS pre_crash,
occurred_at AT TIME ZONE 'UTC' AS occurred_at
"""
# How far apart a device-reported boot timestamp (telemetry.get_boot_history
# "ts", device clock) and our own occurred_at (broker receive time of the live
# boot_report) may be and still count as the same boot. The live report goes
# out on first MQTT connect, so it normally lands within a minute of boot.
_BOOT_MATCH_WINDOW = timedelta(minutes=30)
def _json_or_none(obj: dict | None) -> str | None:
"""Encode for a CAST(CAST(:x AS TEXT) AS JSONB) bind. Passed as text on
purpose: a bare CAST(:x AS JSONB) makes asyncpg type the parameter as jsonb
and JSON-encode our already-encoded string a second time."""
return json.dumps(obj) if obj else None
def _boot_row_to_dict(row) -> dict:
d = _row_to_dict(row)
for key in ("crash", "pre_crash"):
val = d.get(key)
if isinstance(val, str):
try:
d[key] = json.loads(val)
except ValueError:
d[key] = None
return d
def _crash_columns(crash: dict | None) -> dict:
"""The legacy flat crash_* columns, still filled so older readers and the
fleet grouping query keep working without unpacking JSON."""
crash = crash or {}
return {
"crash_task": crash.get("task"),
"crash_pc": crash.get("pc"),
"crash_exc_cause": crash.get("exc_cause"),
"crash_exc_vaddr": crash.get("exc_vaddr"),
}
async def insert_boot_event(device_serial: str, boot_count: int | None, async def insert_boot_event(device_serial: str, boot_count: int | None,
reset_reason: str | None, is_fault: bool, reset_reason: str | None, is_fault: bool,
free_heap: int | None, free_heap: int | None,
crash_task: str | None = None, crash: dict | None = None,
crash_pc: int | None = None, pre_crash: dict | None = None,
crash_exc_cause: int | None = None,
crash_exc_vaddr: int | None = None,
skip_if_latest: bool = False) -> int | None: skip_if_latest: bool = False) -> int | None:
"""Insert a boot event. With skip_if_latest=True (a retained replay of the """Insert a boot event. With skip_if_latest=True (a retained replay of the
device's last boot_report), skip it and return None when it matches the device's last boot_report), skip it and return None when it matches the
@@ -452,7 +495,10 @@ async def insert_boot_event(device_serial: str, boot_count: int | None,
Only the LATEST row is compared, never "any row with this boot_count": the Only the LATEST row is compared, never "any row with this boot_count": the
firmware's lifetime counter gets reset (reflash / telemetry reset), so the firmware's lifetime counter gets reset (reflash / telemetry reset), so the
same boot_count legitimately appears again for a later, different boot.""" same boot_count legitimately appears again for a later, different boot.
`crash` / `pre_crash` are the firmware's objects stored verbatim (F-070);
both are omitted by older firmware and simply stay NULL."""
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
if skip_if_latest and boot_count is not None: if skip_if_latest and boot_count is not None:
latest = await session.execute( latest = await session.execute(
@@ -471,10 +517,12 @@ async def insert_boot_event(device_serial: str, boot_count: int | None,
text(""" text("""
INSERT INTO device_boot_events INSERT INTO device_boot_events
(device_serial, boot_count, reset_reason, is_fault, free_heap, (device_serial, boot_count, reset_reason, is_fault, free_heap,
crash_task, crash_pc, crash_exc_cause, crash_exc_vaddr, occurred_at) crash_task, crash_pc, crash_exc_cause, crash_exc_vaddr,
crash, pre_crash, occurred_at)
VALUES VALUES
(:serial, :boot_count, :reset_reason, :is_fault, :free_heap, (:serial, :boot_count, :reset_reason, :is_fault, :free_heap,
:crash_task, :crash_pc, :crash_exc_cause, :crash_exc_vaddr, now()) :crash_task, :crash_pc, :crash_exc_cause, :crash_exc_vaddr,
CAST(CAST(:crash AS TEXT) AS JSONB), CAST(CAST(:pre_crash AS TEXT) AS JSONB), now())
RETURNING id RETURNING id
"""), """),
{ {
@@ -483,10 +531,9 @@ async def insert_boot_event(device_serial: str, boot_count: int | None,
"reset_reason": reset_reason, "reset_reason": reset_reason,
"is_fault": is_fault, "is_fault": is_fault,
"free_heap": free_heap, "free_heap": free_heap,
"crash_task": crash_task, **_crash_columns(crash),
"crash_pc": crash_pc, "crash": _json_or_none(crash),
"crash_exc_cause": crash_exc_cause, "pre_crash": _json_or_none(pre_crash),
"crash_exc_vaddr": crash_exc_vaddr,
}, },
) )
row = result.fetchone() row = result.fetchone()
@@ -494,6 +541,102 @@ async def insert_boot_event(device_serial: str, boot_count: int | None,
return row[0] if row else None return row[0] if row else None
async def merge_device_boot_history(device_serial: str, boots: list[dict]) -> dict:
"""Merge the device's own SD boot log (telemetry.get_boot_history reply)
into device_boot_events.
Each entry is {"ts": epoch s, "boot": n, "reason": str, "crash"?, "pre_crash"?}.
An entry matches an existing row when boot_count and reset_reason are equal
and the times are within _BOOT_MATCH_WINDOW (boot_count alone is not unique,
see insert_boot_event). A match only fills in crash/pre_crash the row is
missing; nothing already stored is overwritten. An entry with no match is a
boot we never saw live and is inserted at the device's timestamp. Entries
without a usable ts can't be placed in time and are skipped.
"""
inserted = updated = skipped = 0
async with AsyncSessionLocal() as session:
for entry in boots:
if not isinstance(entry, dict):
skipped += 1
continue
ts = entry.get("ts")
boot_count = entry.get("boot")
reason = entry.get("reason")
if not isinstance(ts, (int, float)) or ts <= 0:
skipped += 1
continue
at = datetime.fromtimestamp(ts, tz=timezone.utc)
crash = entry.get("crash") if isinstance(entry.get("crash"), dict) else None
pre_crash = entry.get("pre_crash") if isinstance(entry.get("pre_crash"), dict) else None
match = (await session.execute(
text("""
SELECT id, crash IS NULL AS no_crash, pre_crash IS NULL AS no_pre_crash
FROM device_boot_events
WHERE device_serial = :serial
AND boot_count IS NOT DISTINCT FROM :boot_count
AND reset_reason IS NOT DISTINCT FROM :reason
AND occurred_at BETWEEN :lo AND :hi
ORDER BY abs(extract(epoch FROM occurred_at - :at))
LIMIT 1
"""),
{"serial": device_serial, "boot_count": boot_count, "reason": reason,
"lo": at - _BOOT_MATCH_WINDOW, "hi": at + _BOOT_MATCH_WINDOW, "at": at},
)).first()
if match is None:
await session.execute(
text("""
INSERT INTO device_boot_events
(device_serial, boot_count, reset_reason, is_fault, free_heap,
crash_task, crash_pc, crash_exc_cause, crash_exc_vaddr,
crash, pre_crash, occurred_at)
VALUES
(:serial, :boot_count, :reason, :is_fault, NULL,
:crash_task, :crash_pc, :crash_exc_cause, :crash_exc_vaddr,
CAST(CAST(:crash AS TEXT) AS JSONB), CAST(CAST(:pre_crash AS TEXT) AS JSONB), :at)
"""),
{
"serial": device_serial, "boot_count": boot_count, "reason": reason,
"is_fault": bool(entry.get("is_fault", reason in FAULT_RESET_REASONS)),
**_crash_columns(crash),
"crash": _json_or_none(crash), "pre_crash": _json_or_none(pre_crash),
"at": at,
},
)
inserted += 1
continue
fill_crash = crash is not None and match.no_crash
fill_pre = pre_crash is not None and match.no_pre_crash
if not (fill_crash or fill_pre):
continue
sets, params = [], {"id": match.id}
if fill_crash:
sets.append("crash = CAST(CAST(:crash AS TEXT) AS JSONB)")
sets.append("crash_task = COALESCE(crash_task, :crash_task)")
sets.append("crash_pc = COALESCE(crash_pc, :crash_pc)")
sets.append("crash_exc_cause = COALESCE(crash_exc_cause, :crash_exc_cause)")
sets.append("crash_exc_vaddr = COALESCE(crash_exc_vaddr, :crash_exc_vaddr)")
params.update(_crash_columns(crash))
params["crash"] = _json_or_none(crash)
if fill_pre:
sets.append("pre_crash = CAST(CAST(:pre_crash AS TEXT) AS JSONB)")
params["pre_crash"] = _json_or_none(pre_crash)
await session.execute(
text(f"UPDATE device_boot_events SET {', '.join(sets)} WHERE id = :id"),
params,
)
updated += 1
await session.commit()
return {"inserted": inserted, "updated": updated, "skipped": skipped}
# Mirrors the firmware's isFaultReset() — used only when a history entry
# doesn't say is_fault itself.
FAULT_RESET_REASONS = {"PANIC", "TASK_WATCHDOG", "INTERRUPT_WATCHDOG", "OTHER_WATCHDOG", "BROWNOUT"}
async def get_boot_events(device_serial: str, limit: int = 100, offset: int = 0, async def get_boot_events(device_serial: str, limit: int = 100, offset: int = 0,
since: datetime | None = None, since: datetime | None = None,
until: datetime | None = None) -> tuple[list, int]: until: datetime | None = None) -> tuple[list, int]:
@@ -509,9 +652,7 @@ async def get_boot_events(device_serial: str, limit: int = 100, offset: int = 0,
rows_result = await session.execute( rows_result = await session.execute(
text(f""" text(f"""
SELECT id, device_serial, boot_count, reset_reason, is_fault, free_heap, SELECT {_BOOT_COLUMNS}
crash_task, crash_pc, crash_exc_cause, crash_exc_vaddr,
occurred_at AT TIME ZONE 'UTC' AS occurred_at
FROM device_boot_events FROM device_boot_events
WHERE device_serial = :serial{range_clause} WHERE device_serial = :serial{range_clause}
ORDER BY occurred_at DESC ORDER BY occurred_at DESC
@@ -521,16 +662,14 @@ async def get_boot_events(device_serial: str, limit: int = 100, offset: int = 0,
) )
rows = rows_result.mappings().all() rows = rows_result.mappings().all()
return [_row_to_dict(r) for r in rows], total return [_boot_row_to_dict(r) for r in rows], total
async def get_latest_boot_event(device_serial: str) -> dict | None: async def get_latest_boot_event(device_serial: str) -> dict | None:
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
result = await session.execute( result = await session.execute(
text(""" text(f"""
SELECT id, device_serial, boot_count, reset_reason, is_fault, free_heap, SELECT {_BOOT_COLUMNS}
crash_task, crash_pc, crash_exc_cause, crash_exc_vaddr,
occurred_at AT TIME ZONE 'UTC' AS occurred_at
FROM device_boot_events FROM device_boot_events
WHERE device_serial = :serial WHERE device_serial = :serial
ORDER BY occurred_at DESC ORDER BY occurred_at DESC
@@ -540,7 +679,32 @@ async def get_latest_boot_event(device_serial: str) -> dict | None:
) )
row = result.mappings().first() row = result.mappings().first()
return _row_to_dict(row) if row else None return _boot_row_to_dict(row) if row else None
async def get_crash_events(since: datetime | None = None,
until: datetime | None = None,
limit: int = 5000) -> list:
"""Fleet-wide fault boots that carry any crash detail (coredump summary
and/or pre-crash snapshot), newest first. Grouped in mqtt/crash_groups.py."""
async with AsyncSessionLocal() as session:
params: dict = {"limit": limit}
range_clause = _time_range_clause("occurred_at", since, until, params)
rows_result = await session.execute(
text(f"""
SELECT {_BOOT_COLUMNS}
FROM device_boot_events
WHERE is_fault
AND (crash IS NOT NULL OR pre_crash IS NOT NULL OR crash_task IS NOT NULL)
{range_clause}
ORDER BY occurred_at DESC
LIMIT :limit
"""),
params,
)
rows = rows_result.mappings().all()
return [_boot_row_to_dict(r) for r in rows]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+90
View File
@@ -0,0 +1,90 @@
"""Fleet-wide crash grouping (firmware F-070).
Groups fault boots so recurring crash types are visible across devices:
- with an abort_msg: by the message with hex addresses stripped, since the
same assert/abort reports different addresses per build;
- otherwise: by faulting task + Xtensa exception cause;
- fault resets with no coredump and no abort_msg: by reset reason alone.
When abort_msg is present, pc/exc_cause/exc_vaddr describe the abort()
mechanism (always StoreProhibited at 0x0), so they must NOT drive the grouping.
"""
import re
# Xtensa EXCCAUSE values the ESP32 actually produces.
EXC_CAUSE_NAMES = {
0: "IllegalInstruction",
2: "InstructionFetchError",
3: "LoadStoreError",
6: "IntegerDivideByZero",
9: "LoadStoreAlignment",
28: "LoadProhibited",
29: "StoreProhibited",
}
_HEX = re.compile(r"0x[0-9a-fA-F]+")
_SPACES = re.compile(r"\s+")
def normalize_abort_msg(msg: str) -> str:
return _SPACES.sub(" ", _HEX.sub("0x…", msg)).strip()
def exc_cause_name(cause) -> str:
if cause is None:
return "Unknown exception"
return EXC_CAUSE_NAMES.get(cause, f"Exception cause {cause}")
def group_key(event: dict) -> tuple[str, str, str]:
"""(key, kind, title) for one boot event."""
crash = event.get("crash") or {}
pre = event.get("pre_crash") or {}
abort_msg = pre.get("abort_msg")
if abort_msg:
norm = normalize_abort_msg(abort_msg)
return f"abort:{norm}", "abort", norm
task = crash.get("task", event.get("crash_task"))
cause = crash.get("exc_cause", event.get("crash_exc_cause"))
if task is not None or cause is not None:
title = f"{exc_cause_name(cause)} in {task or 'unknown task'}"
return f"exc:{task}:{cause}", "exception", title
reason = event.get("reset_reason") or "UNKNOWN"
return f"reason:{reason}", "reason", f"{reason.replace('_', ' ')} (no coredump)"
def group_crashes(events: list[dict]) -> list[dict]:
"""events must be newest-first (as get_crash_events returns them)."""
groups: dict[str, dict] = {}
for ev in events:
key, kind, title = group_key(ev)
g = groups.get(key)
if g is None:
g = groups[key] = {
"key": key,
"kind": kind,
"title": title,
"count": 0,
"first_at": ev["occurred_at"],
"last_at": ev["occurred_at"],
"latest": ev,
"devices": {},
}
g["count"] += 1
g["first_at"] = ev["occurred_at"] # newest-first input, so the last seen is the oldest
d = g["devices"].setdefault(ev["device_serial"], {
"device_serial": ev["device_serial"], "count": 0, "last_at": ev["occurred_at"],
})
d["count"] += 1
result = []
for g in groups.values():
devices = sorted(g["devices"].values(), key=lambda d: (-d["count"], d["device_serial"]))
result.append({**g, "devices": devices, "device_count": len(devices)})
# Most frequent first; ties broken by most recent (two stable sorts).
result.sort(key=lambda g: g["last_at"], reverse=True)
result.sort(key=lambda g: g["count"], reverse=True)
return result
+16 -5
View File
@@ -163,17 +163,19 @@ async def _handle_boot_report(serial: str, payload: dict, retained: bool = False
info event as { "type": ..., "payload": {...} }. info event as { "type": ..., "payload": {...} }.
""" """
data = payload.get("payload", {}) data = payload.get("payload", {})
crash = data.get("crash") or {} # F-070: `crash` (coredump summary, now incl. backtrace/elf_sha256) and
# `pre_crash` (heap/uptime snapshot + optional abort_msg) are stored
# verbatim. Older firmware omits them / sends the 4-field crash only.
crash = data.get("crash") if isinstance(data.get("crash"), dict) else None
pre_crash = data.get("pre_crash") if isinstance(data.get("pre_crash"), dict) else None
await db.insert_boot_event( await db.insert_boot_event(
device_serial=serial, device_serial=serial,
boot_count=data.get("boot_count"), boot_count=data.get("boot_count"),
reset_reason=data.get("reset_reason"), reset_reason=data.get("reset_reason"),
is_fault=bool(data.get("is_fault", False)), is_fault=bool(data.get("is_fault", False)),
free_heap=data.get("free_heap"), free_heap=data.get("free_heap"),
crash_task=crash.get("task"), crash=crash,
crash_pc=crash.get("pc"), pre_crash=pre_crash,
crash_exc_cause=crash.get("exc_cause"),
crash_exc_vaddr=crash.get("exc_vaddr"),
# A live boot_report is always a new boot. A retained replay is the # A live boot_report is always a new boot. A retained replay is the
# device's last boot, recorded only if we missed it while down. # device's last boot, recorded only if we missed it while down.
skip_if_latest=retained, skip_if_latest=retained,
@@ -252,6 +254,15 @@ async def _handle_ack(serial: str, payload: dict):
await db.insert_ping_sample(device_serial=serial, rtt_ms=rtt_ms) await db.insert_ping_sample(device_serial=serial, rtt_ms=rtt_ms)
return return
# The device's own SD boot log — merged into device_boot_events whoever
# asked for it (Health tab "Sync from device", API reference, Control tab),
# so crash detail for boots we never saw live isn't lost.
if payload.get("type") == "telemetry.get_boot_history" and status == "SUCCESS":
boots = (payload.get("data") or {}).get("boots")
if isinstance(boots, list):
result = await db.merge_device_boot_history(serial, boots)
logger.info(f"Merged device boot history for {serial}: {result}")
pending = await db.get_pending_command(serial) pending = await db.get_pending_command(serial)
if pending: if pending:
cmd_status = "success" if status == "SUCCESS" else "error" cmd_status = "success" if status == "SUCCESS" else "error"
+26
View File
@@ -129,6 +129,9 @@ class BootEventEntry(BaseModel):
crash_pc: Optional[int] = None crash_pc: Optional[int] = None
crash_exc_cause: Optional[int] = None crash_exc_cause: Optional[int] = None
crash_exc_vaddr: Optional[int] = None crash_exc_vaddr: Optional[int] = None
# Firmware F-070 objects, verbatim — None for older firmware/rows.
crash: Optional[Dict[str, Any]] = None
pre_crash: Optional[Dict[str, Any]] = None
occurred_at: str occurred_at: str
@@ -137,6 +140,29 @@ class BootEventListResponse(BaseModel):
total: int total: int
class CrashGroupDevice(BaseModel):
device_serial: str
count: int
last_at: str
class CrashGroup(BaseModel):
key: str
kind: str # "abort" | "exception" | "reason"
title: str
count: int
device_count: int
first_at: str
last_at: str
latest: BootEventEntry
devices: List[CrashGroupDevice]
class CrashGroupListResponse(BaseModel):
groups: List[CrashGroup]
total_crashes: int
class PingSampleEntry(BaseModel): class PingSampleEntry(BaseModel):
id: int id: int
device_serial: str device_serial: str
+13
View File
@@ -9,7 +9,9 @@ from mqtt.models import (
AlertEventListResponse, BootEventListResponse, PingSampleListResponse, AlertEventListResponse, BootEventListResponse, PingSampleListResponse,
DiagnosticsReportListResponse, LatestMetricsResponse, DiagnosticsReportListResponse, LatestMetricsResponse,
LatestDiagnosticsEntry, LatestPingEntry, DeviceReportListResponse, LatestDiagnosticsEntry, LatestPingEntry, DeviceReportListResponse,
CrashGroupListResponse,
) )
from mqtt.crash_groups import group_crashes
from mqtt.client import mqtt_manager from mqtt.client import mqtt_manager
from mqtt import presence from mqtt import presence
import database as db import database as db
@@ -177,6 +179,17 @@ async def get_device_boot_events(
return BootEventListResponse(events=events, total=total) return BootEventListResponse(events=events, total=total)
@router.get("/crash-groups", response_model=CrashGroupListResponse)
async def get_crash_groups(
since: Optional[datetime] = Query(None, description="ISO timestamp — only crashes at/after this time"),
until: Optional[datetime] = Query(None, description="ISO timestamp — only crashes at/before this time"),
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
):
"""Fault boots across the whole fleet, grouped by crash signature."""
events = await db.get_crash_events(since=since, until=until)
return CrashGroupListResponse(groups=group_crashes(events), total_crashes=len(events))
@router.get("/ping-samples/{device_serial}", response_model=PingSampleListResponse) @router.get("/ping-samples/{device_serial}", response_model=PingSampleListResponse)
async def get_device_ping_samples( async def get_device_ping_samples(
device_serial: str, device_serial: str,