mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-02 20:00:51 +03:00
report notice counts cumulatively from a database baseline
Moving watermarks after each accepted send kept the install ID and the notice baseline out of step: pre-consent notices went out, notices raised before the first send were dropped, and a send that finished after an off/on toggle overwrote the new identity's baseline. health.notices now carries cumulative counts since the install ID was created. Creating an ID copies the lifetime counts into the existing reported_* columns before the state file names it, and nothing is written after a send, so the backend diffs consecutive reports per install ID.
This commit is contained in:
+4
-4
@@ -906,7 +906,7 @@
|
|||||||
"additionalProperties": {
|
"additionalProperties": {
|
||||||
"$ref": "#/$defs/NoticeCounts"
|
"$ref": "#/$defs/NoticeCounts"
|
||||||
},
|
},
|
||||||
"description": "Notice counts by kind since the last accepted report",
|
"description": "Cumulative notice counts by kind since the install ID was created",
|
||||||
"propertyNames": {
|
"propertyNames": {
|
||||||
"$ref": "#/$defs/NoticeKindKey"
|
"$ref": "#/$defs/NoticeKindKey"
|
||||||
},
|
},
|
||||||
@@ -1103,21 +1103,21 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
"acknowledgements": {
|
"acknowledgements": {
|
||||||
"description": "Acknowledgements since the last accepted report",
|
"description": "Acknowledgements since the install ID was created",
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"title": "Acknowledgements",
|
"title": "Acknowledgements",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"x-public": true
|
"x-public": true
|
||||||
},
|
},
|
||||||
"mutes": {
|
"mutes": {
|
||||||
"description": "Mutes since the last accepted report",
|
"description": "Mutes since the install ID was created",
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"title": "Mutes",
|
"title": "Mutes",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"x-public": true
|
"x-public": true
|
||||||
},
|
},
|
||||||
"occurrences": {
|
"occurrences": {
|
||||||
"description": "Occurrences since the last accepted report",
|
"description": "Occurrences since the install ID was created",
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"title": "Occurrences",
|
"title": "Occurrences",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ TIMING_STATS = {
|
|||||||
REPORTABLE_KINDS = frozenset(key.value for key in NoticeKindKey)
|
REPORTABLE_KINDS = frozenset(key.value for key in NoticeKindKey)
|
||||||
|
|
||||||
|
|
||||||
def notice_deltas(notice_stats: list[dict[str, Any]]) -> dict[Any, NoticeCounts]:
|
def notice_counts(notice_stats: list[dict[str, Any]]) -> dict[Any, NoticeCounts]:
|
||||||
"""What changed since the last accepted report, per reportable kind."""
|
"""Counts since the install ID was created, per reportable kind."""
|
||||||
deltas: dict[Any, NoticeCounts] = {}
|
counts: dict[Any, NoticeCounts] = {}
|
||||||
|
|
||||||
for row in notice_stats:
|
for row in notice_stats:
|
||||||
if row["kind"] not in REPORTABLE_KINDS:
|
if row["kind"] not in REPORTABLE_KINDS:
|
||||||
@@ -38,11 +38,11 @@ def notice_deltas(notice_stats: list[dict[str, Any]]) -> dict[Any, NoticeCounts]
|
|||||||
mutes = max(row["mutes"] - row["reported_mutes"], 0)
|
mutes = max(row["mutes"] - row["reported_mutes"], 0)
|
||||||
|
|
||||||
if occurrences or acknowledgements or mutes:
|
if occurrences or acknowledgements or mutes:
|
||||||
deltas[NoticeKindKey(row["kind"])] = NoticeCounts(
|
counts[NoticeKindKey(row["kind"])] = NoticeCounts(
|
||||||
occurrences=occurrences, acknowledgements=acknowledgements, mutes=mutes
|
occurrences=occurrences, acknowledgements=acknowledgements, mutes=mutes
|
||||||
)
|
)
|
||||||
|
|
||||||
return deltas
|
return counts
|
||||||
|
|
||||||
|
|
||||||
def collect(ctx: ReportContext) -> HealthSection:
|
def collect(ctx: ReportContext) -> HealthSection:
|
||||||
@@ -59,5 +59,5 @@ def collect(ctx: ReportContext) -> HealthSection:
|
|||||||
cpu_percent=min(round(rate(cpu)), 100),
|
cpu_percent=min(round(rate(cpu)), 100),
|
||||||
enrichment_ms={timing: value for timing, value in timings.items() if value > 0},
|
enrichment_ms={timing: value for timing, value in timings.items() if value > 0},
|
||||||
retention_unmet=bool(service.get("retention_unmet", False)),
|
retention_unmet=bool(service.get("retention_unmet", False)),
|
||||||
notices=notice_deltas(ctx.notice_stats),
|
notices=notice_counts(ctx.notice_stats),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -85,5 +85,7 @@ def preview_report(
|
|||||||
) -> AnalyticsReport:
|
) -> AnalyticsReport:
|
||||||
"""The report the next send would carry, without sending it."""
|
"""The report the next send would carry, without sending it."""
|
||||||
state = load_state()
|
state = load_state()
|
||||||
ctx = gather_context(config, stats_emitter, notice_registry)
|
|
||||||
|
# an opt-in resets the baseline, so before one there are no notices to show
|
||||||
|
ctx = gather_context(config, stats_emitter, notice_registry if state else None)
|
||||||
return build_report(ctx, state.install_id if state else PREVIEW_INSTALL_ID)
|
return build_report(ctx, state.install_id if state else PREVIEW_INSTALL_ID)
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ from pydantic import ValidationError
|
|||||||
from ruamel.yaml import YAML
|
from ruamel.yaml import YAML
|
||||||
from ruamel.yaml.error import YAMLError
|
from ruamel.yaml.error import YAMLError
|
||||||
|
|
||||||
from frigate.analytics.context import ReportContext
|
from frigate.analytics.report import build_report, gather_context
|
||||||
from frigate.analytics.report import build_report
|
|
||||||
from frigate.analytics.state import (
|
from frigate.analytics.state import (
|
||||||
STATE_PATH,
|
STATE_PATH,
|
||||||
AnalyticsState,
|
AnalyticsState,
|
||||||
@@ -128,16 +127,20 @@ class AnalyticsReporter(threading.Thread):
|
|||||||
if opted_in:
|
if opted_in:
|
||||||
resolve_notice(PROMPT_KIND)
|
resolve_notice(PROMPT_KIND)
|
||||||
|
|
||||||
# a new install ID counts notices from the opt-in, so nothing
|
# the opt-in sets the notice baseline, so only notices raised with
|
||||||
# raised before consent is sent and nothing raised after is lost
|
# consent are counted; tick() retries a failed write
|
||||||
if load_state(self.state_path) is None and save_state(
|
if load_state(self.state_path) is None:
|
||||||
new_state(), self.state_path
|
save_state(self._new_identity(), self.state_path)
|
||||||
):
|
|
||||||
self.notice_registry.mark_reported(self.notice_registry.stats())
|
|
||||||
else:
|
else:
|
||||||
raise_notice(PROMPT_KIND)
|
raise_notice(PROMPT_KIND)
|
||||||
delete_state(self.state_path)
|
delete_state(self.state_path)
|
||||||
|
|
||||||
|
def _new_identity(self) -> AnalyticsState:
|
||||||
|
# called with the lock held; the baseline resets before the state file
|
||||||
|
# names the new ID, so a failed write only means another reset
|
||||||
|
self.notice_registry.reset_analytics_baseline()
|
||||||
|
return new_state()
|
||||||
|
|
||||||
def tick(self) -> None:
|
def tick(self) -> None:
|
||||||
"""Apply consent and send a report when one is due."""
|
"""Apply consent and send a report when one is due."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -158,9 +161,9 @@ class AnalyticsReporter(threading.Thread):
|
|||||||
if now < self.first_due:
|
if now < self.first_due:
|
||||||
return
|
return
|
||||||
|
|
||||||
state = load_state(self.state_path) or new_state()
|
state = load_state(self.state_path)
|
||||||
|
|
||||||
if not self._due(state.last_attempt_at, now):
|
if state is not None and not self._due(state.last_attempt_at, now):
|
||||||
return
|
return
|
||||||
|
|
||||||
# a YAML editor save or a hand edit reaches the live config only on
|
# a YAML editor save or a hand edit reaches the live config only on
|
||||||
@@ -169,10 +172,14 @@ class AnalyticsReporter(threading.Thread):
|
|||||||
delete_state(self.state_path)
|
delete_state(self.state_path)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
state = AnalyticsState(
|
||||||
|
(state or self._new_identity()).install_id, last_attempt_at=now
|
||||||
|
)
|
||||||
|
|
||||||
# saved before sending, so a failing endpoint or a crash mid-send
|
# saved before sending, so a failing endpoint or a crash mid-send
|
||||||
# still waits a full interval; without saved state every boot would
|
# still waits a full interval; without saved state every boot would
|
||||||
# send under a new install ID
|
# send under a new install ID
|
||||||
if not save_state(AnalyticsState(state.install_id, now), self.state_path):
|
if not save_state(state, self.state_path):
|
||||||
if not self.warned_unwritable:
|
if not self.warned_unwritable:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Analytics is on, but %s isn't writable, so no report is sent",
|
"Analytics is on, but %s isn't writable, so no report is sent",
|
||||||
@@ -194,11 +201,8 @@ class AnalyticsReporter(threading.Thread):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _send(self, install_id: str, now: float) -> None:
|
def _send(self, install_id: str, now: float) -> None:
|
||||||
notice_stats = self.notice_registry.stats()
|
ctx = gather_context(
|
||||||
ctx = ReportContext(
|
self.config_holder.config, self.stats_emitter, self.notice_registry
|
||||||
config=self.config_holder.config,
|
|
||||||
stats=self.stats_emitter.get_latest_stats(),
|
|
||||||
notice_stats=notice_stats,
|
|
||||||
)
|
)
|
||||||
report = build_report(ctx, install_id, sent_at=int(now))
|
report = build_report(ctx, install_id, sent_at=int(now))
|
||||||
body = report.model_dump_json()
|
body = report.model_dump_json()
|
||||||
@@ -210,9 +214,5 @@ class AnalyticsReporter(threading.Thread):
|
|||||||
if self.send(self.url, body) is not SendOutcome.accepted:
|
if self.send(self.url, body) is not SendOutcome.accepted:
|
||||||
return
|
return
|
||||||
|
|
||||||
# a failed health section sent no notice counts, so they stay pending
|
|
||||||
if report.health is not None:
|
|
||||||
self.notice_registry.mark_reported(notice_stats)
|
|
||||||
|
|
||||||
logger.info("Sent the daily analytics report")
|
logger.info("Sent the daily analytics report")
|
||||||
logger.debug("Analytics report: %s", body)
|
logger.debug("Analytics report: %s", body)
|
||||||
|
|||||||
@@ -378,9 +378,9 @@ class FeaturesSection(AnalyticsModel):
|
|||||||
|
|
||||||
|
|
||||||
class NoticeCounts(AnalyticsModel):
|
class NoticeCounts(AnalyticsModel):
|
||||||
occurrences: int = count("Occurrences since the last accepted report")
|
occurrences: int = count("Occurrences since the install ID was created")
|
||||||
acknowledgements: int = count("Acknowledgements since the last accepted report")
|
acknowledgements: int = count("Acknowledgements since the install ID was created")
|
||||||
mutes: int = count("Mutes since the last accepted report")
|
mutes: int = count("Mutes since the install ID was created")
|
||||||
|
|
||||||
|
|
||||||
class HealthSection(AnalyticsModel):
|
class HealthSection(AnalyticsModel):
|
||||||
@@ -393,7 +393,8 @@ class HealthSection(AnalyticsModel):
|
|||||||
"Whether storage can't keep the configured retention"
|
"Whether storage can't keep the configured retention"
|
||||||
)
|
)
|
||||||
notices: dict[NoticeKindKey, NoticeCounts] = metric(
|
notices: dict[NoticeKindKey, NoticeCounts] = metric(
|
||||||
"Notice counts by kind since the last accepted report", public=False
|
"Cumulative notice counts by kind since the install ID was created",
|
||||||
|
public=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -209,7 +209,7 @@ class NoticeStats(Model):
|
|||||||
mutes = IntegerField(default=0)
|
mutes = IntegerField(default=0)
|
||||||
first_seen = DateTimeField()
|
first_seen = DateTimeField()
|
||||||
last_seen = DateTimeField()
|
last_seen = DateTimeField()
|
||||||
# counts the last accepted analytics report covered
|
# lifetime counts when the current analytics install ID was created
|
||||||
reported_occurrences = IntegerField(default=0)
|
reported_occurrences = IntegerField(default=0)
|
||||||
reported_acknowledgements = IntegerField(default=0)
|
reported_acknowledgements = IntegerField(default=0)
|
||||||
reported_mutes = IntegerField(default=0)
|
reported_mutes = IntegerField(default=0)
|
||||||
|
|||||||
@@ -385,19 +385,14 @@ class NoticeRegistry:
|
|||||||
if row.kind in NOTICE_KINDS
|
if row.kind in NOTICE_KINDS
|
||||||
]
|
]
|
||||||
|
|
||||||
def mark_reported(self, snapshot: list[dict[str, Any]]) -> None:
|
def reset_analytics_baseline(self) -> None:
|
||||||
"""Move the analytics watermarks to the counts a sent report was built from.
|
"""Count analytics notices from now on, for a new analytics install ID."""
|
||||||
|
|
||||||
Using the snapshot rather than the current counts sends anything raised
|
|
||||||
while the report was in flight with the next one.
|
|
||||||
"""
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
for row in snapshot:
|
NoticeStats.update(
|
||||||
NoticeStats.update(
|
reported_occurrences=NoticeStats.occurrences,
|
||||||
reported_occurrences=row["occurrences"],
|
reported_acknowledgements=NoticeStats.acknowledgements,
|
||||||
reported_acknowledgements=row["acknowledgements"],
|
reported_mutes=NoticeStats.mutes,
|
||||||
reported_mutes=row["mutes"],
|
).execute()
|
||||||
).where(NoticeStats.kind == row["kind"]).execute()
|
|
||||||
|
|
||||||
def _write_repeats(
|
def _write_repeats(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from frigate.test.analytics_helpers import make_context
|
|||||||
|
|
||||||
|
|
||||||
class TestHealthCollector(unittest.TestCase):
|
class TestHealthCollector(unittest.TestCase):
|
||||||
def test_collects_uptime_cpu_timings_and_notice_deltas(self):
|
def test_collects_uptime_cpu_timings_and_notices_since_the_baseline(self):
|
||||||
stats = {
|
stats = {
|
||||||
"service": {"uptime": 7300, "retention_unmet": True},
|
"service": {"uptime": 7300, "retention_unmet": True},
|
||||||
"cpu_usages": {"frigate.full_system": {"cpu": "23.6", "mem": "40"}},
|
"cpu_usages": {"frigate.full_system": {"cpu": "23.6", "mem": "40"}},
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from playhouse.sqliteq import SqliteQueueDatabase
|
|||||||
from frigate.analytics import report
|
from frigate.analytics import report
|
||||||
from frigate.analytics.collectors import hardware
|
from frigate.analytics.collectors import hardware
|
||||||
from frigate.analytics.schema import AnalyticsReport, InstallSection
|
from frigate.analytics.schema import AnalyticsReport, InstallSection
|
||||||
|
from frigate.analytics.state import AnalyticsState
|
||||||
from frigate.models import User
|
from frigate.models import User
|
||||||
from frigate.test.analytics_helpers import make_context
|
from frigate.test.analytics_helpers import make_context
|
||||||
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
|
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
|
||||||
@@ -105,3 +106,38 @@ class TestRealCollectors(unittest.TestCase):
|
|||||||
built = report.preview_report(make_context().config, stats_emitter, None)
|
built = report.preview_report(make_context().config, stats_emitter, None)
|
||||||
|
|
||||||
self.assertEqual(built.install_id, report.PREVIEW_INSTALL_ID)
|
self.assertEqual(built.install_id, report.PREVIEW_INSTALL_ID)
|
||||||
|
|
||||||
|
def test_preview_counts_notices_only_once_opted_in(self):
|
||||||
|
stats_emitter = Mock()
|
||||||
|
stats_emitter.get_latest_stats.return_value = {}
|
||||||
|
registry = Mock()
|
||||||
|
registry.stats.return_value = [
|
||||||
|
{
|
||||||
|
"kind": "detector_stuck",
|
||||||
|
"occurrences": 3,
|
||||||
|
"acknowledgements": 0,
|
||||||
|
"mutes": 0,
|
||||||
|
"reported_occurrences": 1,
|
||||||
|
"reported_acknowledgements": 0,
|
||||||
|
"reported_mutes": 0,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
for state, expected in (
|
||||||
|
(None, {}),
|
||||||
|
(AnalyticsState("a" * 32, 0.0), {"detector_stuck": 2}),
|
||||||
|
):
|
||||||
|
with (
|
||||||
|
self.subTest(opted_in=state is not None),
|
||||||
|
patch.object(report, "load_state", return_value=state),
|
||||||
|
patch.object(hardware.hardware_prober, "probe", return_value=[]),
|
||||||
|
patch.object(hardware, "hwaccel_options", return_value=("", [])),
|
||||||
|
):
|
||||||
|
built = report.preview_report(
|
||||||
|
make_context().config, stats_emitter, registry
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
{kind: c.occurrences for kind, c in built.health.notices.items()},
|
||||||
|
expected,
|
||||||
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import os
|
|||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import Mock, call, patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
from frigate.analytics import reporter as reporter_module
|
from frigate.analytics import reporter as reporter_module
|
||||||
from frigate.analytics.reporter import (
|
from frigate.analytics.reporter import (
|
||||||
@@ -20,18 +20,6 @@ from frigate.analytics.transport import SendOutcome
|
|||||||
from frigate.config.holder import ConfigHolder
|
from frigate.config.holder import ConfigHolder
|
||||||
from frigate.test.analytics_helpers import make_config
|
from frigate.test.analytics_helpers import make_config
|
||||||
|
|
||||||
SNAPSHOT = [
|
|
||||||
{
|
|
||||||
"kind": "detector_stuck",
|
|
||||||
"occurrences": 3,
|
|
||||||
"acknowledgements": 0,
|
|
||||||
"mutes": 0,
|
|
||||||
"reported_occurrences": 1,
|
|
||||||
"reported_acknowledgements": 0,
|
|
||||||
"reported_mutes": 0,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class TestAnalyticsReporter(unittest.TestCase):
|
class TestAnalyticsReporter(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -43,7 +31,6 @@ class TestAnalyticsReporter(unittest.TestCase):
|
|||||||
self.now = 1_790_000_000.0
|
self.now = 1_790_000_000.0
|
||||||
self.send = Mock(return_value=SendOutcome.accepted)
|
self.send = Mock(return_value=SendOutcome.accepted)
|
||||||
self.registry = Mock()
|
self.registry = Mock()
|
||||||
self.registry.stats.return_value = SNAPSHOT
|
|
||||||
self.stats = Mock()
|
self.stats = Mock()
|
||||||
self.stats.get_latest_stats.return_value = {}
|
self.stats.get_latest_stats.return_value = {}
|
||||||
self.holder = ConfigHolder(make_config({"telemetry": {"analytics": True}}))
|
self.holder = ConfigHolder(make_config({"telemetry": {"analytics": True}}))
|
||||||
@@ -63,9 +50,8 @@ class TestAnalyticsReporter(unittest.TestCase):
|
|||||||
with open(self.config_path, "w") as f:
|
with open(self.config_path, "w") as f:
|
||||||
f.write(text)
|
f.write(text)
|
||||||
|
|
||||||
def seed_state(self) -> None:
|
def seed_state(self, last_attempt_at: float = 0.0) -> None:
|
||||||
# an install that has reported before, so no fresh-ID reset runs
|
save_state(AnalyticsState("a" * 32, last_attempt_at), self.path)
|
||||||
save_state(AnalyticsState("a" * 32, 0.0), self.path)
|
|
||||||
|
|
||||||
def reporter(self, path: str | None = None) -> AnalyticsReporter:
|
def reporter(self, path: str | None = None) -> AnalyticsReporter:
|
||||||
# uniform(a, b) returns a: the shortest startup delay and interval
|
# uniform(a, b) returns a: the shortest startup delay and interval
|
||||||
@@ -101,7 +87,6 @@ class TestAnalyticsReporter(unittest.TestCase):
|
|||||||
self.send.assert_called_once_with(
|
self.send.assert_called_once_with(
|
||||||
"https://example.test/report", '{"report": 1}'
|
"https://example.test/report", '{"report": 1}'
|
||||||
)
|
)
|
||||||
self.registry.mark_reported.assert_called_once_with(SNAPSHOT)
|
|
||||||
self.assertEqual(load_state(self.path).last_attempt_at, self.now)
|
self.assertEqual(load_state(self.path).last_attempt_at, self.now)
|
||||||
|
|
||||||
def test_sends_once_per_interval(self):
|
def test_sends_once_per_interval(self):
|
||||||
@@ -118,7 +103,7 @@ class TestAnalyticsReporter(unittest.TestCase):
|
|||||||
self.assertEqual(self.send.call_count, 2)
|
self.assertEqual(self.send.call_count, 2)
|
||||||
|
|
||||||
def test_a_restart_inside_the_interval_does_not_send(self):
|
def test_a_restart_inside_the_interval_does_not_send(self):
|
||||||
save_state(AnalyticsState("a" * 32, self.now), self.path)
|
self.seed_state(self.now)
|
||||||
reporter = self.reporter()
|
reporter = self.reporter()
|
||||||
|
|
||||||
self.advance(FIRST_DELAY_S[0])
|
self.advance(FIRST_DELAY_S[0])
|
||||||
@@ -126,7 +111,7 @@ class TestAnalyticsReporter(unittest.TestCase):
|
|||||||
|
|
||||||
self.send.assert_not_called()
|
self.send.assert_not_called()
|
||||||
|
|
||||||
def test_a_failed_send_keeps_watermarks_and_waits_a_full_interval(self):
|
def test_a_failed_send_waits_a_full_interval(self):
|
||||||
self.send.return_value = SendOutcome.failed
|
self.send.return_value = SendOutcome.failed
|
||||||
self.seed_state()
|
self.seed_state()
|
||||||
reporter = self.reporter()
|
reporter = self.reporter()
|
||||||
@@ -137,10 +122,31 @@ class TestAnalyticsReporter(unittest.TestCase):
|
|||||||
reporter.tick()
|
reporter.tick()
|
||||||
|
|
||||||
self.assertEqual(self.send.call_count, 1)
|
self.assertEqual(self.send.call_count, 1)
|
||||||
self.registry.mark_reported.assert_not_called()
|
|
||||||
|
|
||||||
def test_an_accepted_report_without_health_keeps_the_watermarks(self):
|
def assert_reset_before_the_state_file(self) -> None:
|
||||||
self.built.health = None
|
# the baseline must be in place before the file names the new ID
|
||||||
|
self.registry.reset_analytics_baseline.side_effect = lambda: self.assertIsNone(
|
||||||
|
load_state(self.path)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_the_opt_in_resets_the_baseline_and_writes_the_identity(self):
|
||||||
|
self.assert_reset_before_the_state_file()
|
||||||
|
reporter = self.reporter()
|
||||||
|
|
||||||
|
# consent is seen on the first wake, well before the first send
|
||||||
|
reporter.tick()
|
||||||
|
self.registry.reset_analytics_baseline.assert_called_once()
|
||||||
|
self.assertIsNotNone(load_state(self.path))
|
||||||
|
self.send.assert_not_called()
|
||||||
|
|
||||||
|
# the first report counts from that reset, so nothing resets again
|
||||||
|
self.advance(FIRST_DELAY_S[0])
|
||||||
|
reporter.tick()
|
||||||
|
|
||||||
|
self.send.assert_called_once()
|
||||||
|
self.registry.reset_analytics_baseline.assert_called_once()
|
||||||
|
|
||||||
|
def test_a_restart_with_saved_state_keeps_the_baseline(self):
|
||||||
self.seed_state()
|
self.seed_state()
|
||||||
reporter = self.reporter()
|
reporter = self.reporter()
|
||||||
self.advance(FIRST_DELAY_S[0])
|
self.advance(FIRST_DELAY_S[0])
|
||||||
@@ -148,28 +154,55 @@ class TestAnalyticsReporter(unittest.TestCase):
|
|||||||
reporter.tick()
|
reporter.tick()
|
||||||
|
|
||||||
self.send.assert_called_once()
|
self.send.assert_called_once()
|
||||||
self.registry.mark_reported.assert_not_called()
|
self.registry.reset_analytics_baseline.assert_not_called()
|
||||||
|
|
||||||
def test_a_new_install_id_counts_notices_from_the_opt_in(self):
|
def test_a_settings_opt_in_resets_the_baseline_when_saved(self):
|
||||||
before = [{**SNAPSHOT[0], "occurrences": 2}]
|
self.holder.set(make_config())
|
||||||
self.registry.stats.return_value = before
|
|
||||||
reporter = self.reporter()
|
reporter = self.reporter()
|
||||||
|
|
||||||
# consent is seen on the first wake, well before the first send
|
|
||||||
reporter.tick()
|
reporter.tick()
|
||||||
self.registry.mark_reported.assert_called_once_with(before)
|
|
||||||
|
self.holder.set(make_config({"telemetry": {"analytics": True}}))
|
||||||
|
|
||||||
|
self.registry.reset_analytics_baseline.assert_called_once()
|
||||||
self.assertIsNotNone(load_state(self.path))
|
self.assertIsNotNone(load_state(self.path))
|
||||||
self.send.assert_not_called()
|
|
||||||
|
|
||||||
# a notice raised during the startup delay goes out with the first report
|
def test_a_re_opt_in_during_the_request_keeps_its_new_identity(self):
|
||||||
self.registry.stats.return_value = SNAPSHOT
|
self.seed_state()
|
||||||
|
reporter = self.reporter()
|
||||||
self.advance(FIRST_DELAY_S[0])
|
self.advance(FIRST_DELAY_S[0])
|
||||||
|
created = []
|
||||||
|
|
||||||
|
def flip_consent(*args):
|
||||||
|
self.holder.set(make_config())
|
||||||
|
self.holder.set(make_config({"telemetry": {"analytics": True}}))
|
||||||
|
created.append(load_state(self.path))
|
||||||
|
return SendOutcome.accepted
|
||||||
|
|
||||||
|
self.send.side_effect = flip_consent
|
||||||
reporter.tick()
|
reporter.tick()
|
||||||
|
|
||||||
self.assertIs(self.build_report.call_args.args[0].notice_stats, SNAPSHOT)
|
# nothing after the send touches the new identity or its baseline
|
||||||
self.assertEqual(
|
self.assertNotEqual(created[0].install_id, "a" * 32)
|
||||||
self.registry.mark_reported.call_args_list, [call(before), call(SNAPSHOT)]
|
self.assertEqual(load_state(self.path), created[0])
|
||||||
)
|
self.registry.reset_analytics_baseline.assert_called_once()
|
||||||
|
|
||||||
|
def test_an_opt_in_without_a_writable_state_file_resets_again_later(self):
|
||||||
|
state_dir = os.path.join(self.dir.name, "missing")
|
||||||
|
reporter = self.reporter(os.path.join(state_dir, ".analytics.json"))
|
||||||
|
|
||||||
|
with self.assertLogs("frigate.analytics.reporter", "WARNING"):
|
||||||
|
reporter.tick()
|
||||||
|
self.advance(FIRST_DELAY_S[0])
|
||||||
|
reporter.tick()
|
||||||
|
|
||||||
|
self.send.assert_not_called()
|
||||||
|
os.mkdir(state_dir)
|
||||||
|
self.registry.reset_analytics_baseline.reset_mock()
|
||||||
|
self.advance(WAKE_S)
|
||||||
|
reporter.tick()
|
||||||
|
|
||||||
|
self.send.assert_called_once()
|
||||||
|
self.registry.reset_analytics_baseline.assert_called_once()
|
||||||
|
|
||||||
def test_an_opt_out_saved_only_to_the_file_stops_the_send(self):
|
def test_an_opt_out_saved_only_to_the_file_stops_the_send(self):
|
||||||
self.seed_state()
|
self.seed_state()
|
||||||
@@ -183,6 +216,22 @@ class TestAnalyticsReporter(unittest.TestCase):
|
|||||||
self.assertFalse(os.path.exists(self.path))
|
self.assertFalse(os.path.exists(self.path))
|
||||||
self.raise_notice.assert_not_called()
|
self.raise_notice.assert_not_called()
|
||||||
|
|
||||||
|
def test_reversing_a_file_only_opt_out_resets_the_baseline(self):
|
||||||
|
self.seed_state()
|
||||||
|
self.save_config("telemetry:\n analytics: false\n")
|
||||||
|
reporter = self.reporter()
|
||||||
|
self.advance(FIRST_DELAY_S[0])
|
||||||
|
reporter.tick()
|
||||||
|
|
||||||
|
self.save_config("telemetry:\n analytics: true\n")
|
||||||
|
self.assert_reset_before_the_state_file()
|
||||||
|
self.advance(WAKE_S)
|
||||||
|
reporter.tick()
|
||||||
|
|
||||||
|
self.assertNotEqual(load_state(self.path).install_id, "a" * 32)
|
||||||
|
self.registry.reset_analytics_baseline.assert_called_once()
|
||||||
|
self.send.assert_called_once()
|
||||||
|
|
||||||
def test_yaml_truthy_spellings_in_the_file_count_as_consent(self):
|
def test_yaml_truthy_spellings_in_the_file_count_as_consent(self):
|
||||||
for value in ("yes", "on", "1", "'true'"):
|
for value in ("yes", "on", "1", "'true'"):
|
||||||
with self.subTest(value=value):
|
with self.subTest(value=value):
|
||||||
@@ -222,7 +271,7 @@ class TestAnalyticsReporter(unittest.TestCase):
|
|||||||
self.assertFalse(os.path.exists(self.path))
|
self.assertFalse(os.path.exists(self.path))
|
||||||
|
|
||||||
def test_turning_sharing_off_and_on_between_wakes_starts_a_fresh_identity(self):
|
def test_turning_sharing_off_and_on_between_wakes_starts_a_fresh_identity(self):
|
||||||
save_state(AnalyticsState("a" * 32, 0.0), self.path)
|
self.seed_state()
|
||||||
reporter = self.reporter()
|
reporter = self.reporter()
|
||||||
reporter.tick()
|
reporter.tick()
|
||||||
|
|
||||||
@@ -236,7 +285,7 @@ class TestAnalyticsReporter(unittest.TestCase):
|
|||||||
|
|
||||||
def test_opting_out_raises_the_prompt_once_and_deletes_the_state(self):
|
def test_opting_out_raises_the_prompt_once_and_deletes_the_state(self):
|
||||||
self.holder.set(make_config())
|
self.holder.set(make_config())
|
||||||
save_state(AnalyticsState("a" * 32, 0.0), self.path)
|
self.seed_state()
|
||||||
reporter = self.reporter()
|
reporter = self.reporter()
|
||||||
|
|
||||||
reporter.tick()
|
reporter.tick()
|
||||||
@@ -258,7 +307,7 @@ class TestAnalyticsReporter(unittest.TestCase):
|
|||||||
|
|
||||||
def test_safe_mode_touches_nothing(self):
|
def test_safe_mode_touches_nothing(self):
|
||||||
self.holder.set(make_config({"safe_mode": True}))
|
self.holder.set(make_config({"safe_mode": True}))
|
||||||
save_state(AnalyticsState("a" * 32, 0.0), self.path)
|
self.seed_state()
|
||||||
reporter = self.reporter()
|
reporter = self.reporter()
|
||||||
|
|
||||||
self.advance(FIRST_DELAY_S[0])
|
self.advance(FIRST_DELAY_S[0])
|
||||||
@@ -281,7 +330,7 @@ class TestAnalyticsReporter(unittest.TestCase):
|
|||||||
self.assertEqual(len(logs.output), 1)
|
self.assertEqual(len(logs.output), 1)
|
||||||
|
|
||||||
def test_a_last_attempt_in_the_future_is_ignored(self):
|
def test_a_last_attempt_in_the_future_is_ignored(self):
|
||||||
save_state(AnalyticsState("a" * 32, self.now + 10 * INTERVAL_S), self.path)
|
self.seed_state(self.now + 10 * INTERVAL_S)
|
||||||
reporter = self.reporter()
|
reporter = self.reporter()
|
||||||
|
|
||||||
self.advance(FIRST_DELAY_S[0])
|
self.advance(FIRST_DELAY_S[0])
|
||||||
|
|||||||
@@ -357,23 +357,27 @@ class TestNoticeRegistry(RegistryTestCase):
|
|||||||
self.assertEqual(stats["skipped_detections"]["mutes"], 1)
|
self.assertEqual(stats["skipped_detections"]["mutes"], 1)
|
||||||
|
|
||||||
|
|
||||||
class TestMarkReported(RegistryTestCase):
|
class TestAnalyticsBaseline(RegistryTestCase):
|
||||||
def test_moves_watermarks_to_the_snapshot_not_the_current_count(self):
|
def test_reset_counts_from_now_and_new_kinds_start_at_zero(self):
|
||||||
|
stuck = {"scope": "ov", "params": {"detector": "ov"}}
|
||||||
|
self.registry.raise_notice("detector_stuck", **stuck)
|
||||||
|
self.registry.raise_notice("detector_stuck", **stuck)
|
||||||
|
|
||||||
|
self.registry.reset_analytics_baseline()
|
||||||
|
self.registry.raise_notice("detector_stuck", **stuck)
|
||||||
self.registry.raise_notice(
|
self.registry.raise_notice(
|
||||||
"detector_stuck", scope="ov", params={"detector": "ov"}
|
"skipped_detections", scope="front_door", params={"pct": 12.5}
|
||||||
)
|
|
||||||
snapshot = self.registry.stats()
|
|
||||||
self.registry.raise_notice(
|
|
||||||
"detector_stuck", scope="ov", params={"detector": "ov"}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.registry.mark_reported(snapshot)
|
rows = {row["kind"]: row for row in self.registry.stats()}
|
||||||
|
self.assertEqual(
|
||||||
row = self.registry.stats()[0]
|
(
|
||||||
self.assertEqual(row["occurrences"], 2)
|
rows["detector_stuck"]["occurrences"],
|
||||||
self.assertEqual(row["reported_occurrences"], 1)
|
rows["detector_stuck"]["reported_occurrences"],
|
||||||
self.assertEqual(row["reported_acknowledgements"], 0)
|
),
|
||||||
self.assertEqual(row["reported_mutes"], 0)
|
(3, 2),
|
||||||
|
)
|
||||||
|
self.assertEqual(rows["skipped_detections"]["reported_occurrences"], 0)
|
||||||
|
|
||||||
|
|
||||||
class TestApply(RegistryTestCase):
|
class TestApply(RegistryTestCase):
|
||||||
|
|||||||
Reference in New Issue
Block a user