From 50169b6e51acea793a18034751cbbee9c8343873 Mon Sep 17 00:00:00 2001 From: Hayden <154503486+groupthinking@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:04:19 -0500 Subject: [PATCH 1/4] perf: batch performance-metric writes into one SQLite round-trip record_metric opens a connection, INSERTs one row and commits, per metric. The background monitor emits 7 metrics every 30s and the /performance/report endpoint replays a whole client batch through the same path, so a browser sending 50 samples cost 50 connect+commit cycles. Add record_metrics()/_store_metrics(), which take the lock once, extend the buffer and fast-access deques in one critical section, and persist the whole batch with a single connect -> executemany -> commit. record_metric and _store_metric are unchanged for the ~15 genuine single-metric callers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../backend/api/v1/router.py | 14 +- .../backend/services/performance_monitor.py | 140 ++++++++++- tests/unit/test_performance_monitor.py | 235 ++++++++++++++++++ 3 files changed, 379 insertions(+), 10 deletions(-) diff --git a/src/youtube_extension/backend/api/v1/router.py b/src/youtube_extension/backend/api/v1/router.py index 7bc80726b..020ff9187 100644 --- a/src/youtube_extension/backend/api/v1/router.py +++ b/src/youtube_extension/backend/api/v1/router.py @@ -1177,12 +1177,20 @@ async def ingest_performance_report_v1(report: dict[str, Any]): metrics: dict[str, Any] = ( report.get("metrics", {}) if isinstance(report, dict) else {} ) + # Collect first, then write once. A report carries every web-vital the + # page gathered, and recording them one at a time cost one SQLite + # connection and one commit fsync each while the client waited. + samples: list[dict[str, Any]] = [] for name, stats in metrics.items(): value = stats.get("current") if isinstance(stats, dict) else None if isinstance(value, (int, float)): - await performance_monitor.record_metric( - "frontend", name, float(value), unit=str(stats.get("unit", "ms")) - ) + samples.append({ + "component": "frontend", + "metric_name": name, + "value": float(value), + "unit": str(stats.get("unit", "ms")), + }) + await performance_monitor.record_metrics(samples) return {"status": "ok", "metrics_recorded": len(metrics)} except Exception as e: logger.error(f"Failed to ingest performance report: {e}", exc_info=True) diff --git a/src/youtube_extension/backend/services/performance_monitor.py b/src/youtube_extension/backend/services/performance_monitor.py index 1c89fccb0..6d3d1d28e 100644 --- a/src/youtube_extension/backend/services/performance_monitor.py +++ b/src/youtube_extension/backend/services/performance_monitor.py @@ -24,6 +24,7 @@ import threading import time from collections import defaultdict, deque +from collections.abc import Mapping, Sequence from dataclasses import dataclass from datetime import datetime, timedelta, timezone from typing import Any @@ -268,6 +269,103 @@ async def record_metric(self, except Exception as e: logger.error(f"Failed to record metric: {e}") + async def record_metrics(self, metrics: Sequence[Mapping[str, Any]]) -> None: + """Record several metrics with a single database round-trip. + + Each mapping takes the same keys as :meth:`record_metric` -- + ``component``, ``metric_name``, ``value``, and optionally ``unit`` and + ``tags``. The observable behaviour is identical to calling + ``record_metric`` once per entry: the buffer, the fast-access + collections and the alert thresholds are all updated the same way, in + the same order. + + The difference is the write. ``record_metric`` opens a connection, + inserts one row, commits and closes, so a caller holding N metrics pays + N connections and -- far more expensively -- N commit fsyncs. This + collapses them into one connection, one ``executemany`` and one commit. + + Batching rather than parallelising is deliberate: SQLite serialises + writers behind a single database-level write lock, so concurrent + writers would queue anyway while adding lock contention on top of the + same number of fsyncs. + """ + if not metrics: + return + + try: + now = datetime.now(timezone.utc) + records = [ + PerformanceMetric( + component=entry["component"], + metric_name=entry["metric_name"], + value=float(entry["value"]), + timestamp=entry.get("timestamp") or now, + unit=entry.get("unit", "ms"), + tags=entry.get("tags") or {}, + ) + for entry in metrics + ] + + # Mirror record_metric's buffer bookkeeping, but take the lock once + # for the whole batch instead of once per metric. + with self._lock: + for metric in records: + self.metrics_buffer.append(metric) + + if metric.metric_name == "video_processing_time": + self.video_processing_times.append(metric.value) + elif metric.metric_name == "database_query_time": + self.database_query_times.append(metric.value) + elif metric.metric_name == "api_response_time": + self.api_response_times.append(metric.value) + + # The one write that replaces N. + await self._store_metrics(records) + + # Thresholds are evaluated per metric exactly as before; an alert + # for one metric must not suppress the rest. + for metric in records: + await self._check_alert_thresholds(metric) + + logger.debug(f"📊 Recorded {len(records)} metrics in one write") + + except Exception as e: + logger.error(f"Failed to record metrics: {e}") + + async def _store_metrics(self, metrics: Sequence[PerformanceMetric]) -> None: + """Persist a batch of metrics using one connection and one commit.""" + if not metrics: + return + + rows = [ + ( + metric.component, + metric.metric_name, + metric.value, + metric.unit, + json.dumps(metric.tags), + metric.timestamp.isoformat(), + ) + for metric in metrics + ] + + def _write() -> None: + conn = sqlite3.connect(self.db_path) + try: + conn.executemany(''' + INSERT INTO performance_metrics + (component, metric_name, value, unit, tags, timestamp) + VALUES (?, ?, ?, ?, ?, ?) + ''', rows) + conn.commit() + finally: + conn.close() + + try: + await asyncio.to_thread(_write) + except Exception as e: + logger.error(f"Failed to store metrics in database: {e}") + async def _store_metric(self, metric: PerformanceMetric): """Store metric in database""" @@ -419,29 +517,57 @@ async def _send_alert_notification(self, alert: PerformanceAlert): async def _monitor_system_resources(self): """Monitor system resource usage""" try: + samples: list[dict[str, Any]] = [] + # CPU usage cpu_percent = psutil.cpu_percent(interval=1) - await self.record_metric("system", "cpu_usage_percent", cpu_percent, "%") + samples.append({ + "component": "system", "metric_name": "cpu_usage_percent", + "value": cpu_percent, "unit": "%", + }) # Memory usage memory = psutil.virtual_memory() - await self.record_metric("system", "memory_usage_percent", memory.percent, "%") - await self.record_metric("system", "memory_available_bytes", memory.available, "bytes") + samples.append({ + "component": "system", "metric_name": "memory_usage_percent", + "value": memory.percent, "unit": "%", + }) + samples.append({ + "component": "system", "metric_name": "memory_available_bytes", + "value": memory.available, "unit": "bytes", + }) # Disk usage disk = psutil.disk_usage('/') disk_percent = (disk.used / disk.total) * 100 - await self.record_metric("system", "disk_usage_percent", disk_percent, "%") + samples.append({ + "component": "system", "metric_name": "disk_usage_percent", + "value": disk_percent, "unit": "%", + }) # Process-specific metrics if available try: process = psutil.Process() - await self.record_metric("process", "memory_usage_mb", process.memory_info().rss / 1024 / 1024, "MB") - await self.record_metric("process", "cpu_percent", process.cpu_percent(), "%") - await self.record_metric("process", "threads_count", process.num_threads(), "count") + samples.append({ + "component": "process", "metric_name": "memory_usage_mb", + "value": process.memory_info().rss / 1024 / 1024, "unit": "MB", + }) + samples.append({ + "component": "process", "metric_name": "cpu_percent", + "value": process.cpu_percent(), "unit": "%", + }) + samples.append({ + "component": "process", "metric_name": "threads_count", + "value": process.num_threads(), "unit": "count", + }) except Exception: pass # Process monitoring is optional + # One connection and one commit for the whole cycle, instead of one + # per metric. This loop runs every 30s for the life of the process, + # so the saving is ~17k connections and fsyncs per day. + await self.record_metrics(samples) + except Exception as e: logger.error(f"Error monitoring system resources: {e}") diff --git a/tests/unit/test_performance_monitor.py b/tests/unit/test_performance_monitor.py index b5fcf603c..478ba9f11 100644 --- a/tests/unit/test_performance_monitor.py +++ b/tests/unit/test_performance_monitor.py @@ -1144,3 +1144,238 @@ def _boom(*_args, **_kwargs): with patch.object(perf_mod.sqlite3, "connect", _boom): await monitor._store_metric(metric) # must not raise + + +# =========================================================================== +# PerformanceMonitor — record_metrics / _store_metrics (batched writes) +# =========================================================================== + + +class TestRecordMetricsBatch: + """The batch path must be a drop-in for N serial record_metric calls. + + Everything observable -- rows persisted, buffer contents, fast-access + collections, alerts -- has to match. The only difference permitted is the + number of database connections, which is the entire point. + """ + + @pytest.fixture + def monitor(self, tmp_path): + return self._quiesced(tmp_path / "perf.db") + + @staticmethod + def _quiesced(db_path): + """Build a monitor with its background task stopped. + + ``__init__`` calls ``start_monitoring()`` whenever an event loop is + running, which every async test provides. That task records real + system metrics into the same buffer these tests assert on, so it has + to be cancelled before the buffer means anything. + """ + mon = PerformanceMonitor(db_path=str(db_path)) + mon.monitoring_enabled = False + if mon.monitoring_task is not None: + mon.monitoring_task.cancel() + mon.monitoring_task = None + return mon + + @staticmethod + def _counting_connect(counter): + """Wrap sqlite3.connect so we can count how many times it is called.""" + real_connect = sqlite3.connect + + def _wrapped(*args, **kwargs): + counter.append(1) + return real_connect(*args, **kwargs) + + return _wrapped + + @staticmethod + def _samples(n): + return [ + { + "component": "system", + "metric_name": f"metric_{i}", + "value": float(i), + "unit": "ms", + } + for i in range(n) + ] + + async def test_batch_opens_exactly_one_connection(self, monitor): + """Seven metrics must cost one connection, not seven. + + This is the regression guard for the whole change. The background + monitor records seven metrics every thirty seconds forever, so a + per-metric connection is ~20k connections and commit fsyncs a day. + """ + calls: list[int] = [] + with patch.object(perf_mod.sqlite3, "connect", self._counting_connect(calls)): + await monitor.record_metrics(self._samples(7)) + + assert len(calls) == 1, f"expected 1 connection for the batch, got {len(calls)}" + + async def test_serial_path_still_opens_one_connection_per_metric(self, monitor): + """Pins the old behaviour so the comparison above is meaningful. + + record_metric is left untouched by this change -- ~15 callers record a + single metric and gain nothing from batching -- so it should still + connect once per call. + """ + calls: list[int] = [] + with patch.object(perf_mod.sqlite3, "connect", self._counting_connect(calls)): + for sample in self._samples(7): + await monitor.record_metric( + sample["component"], sample["metric_name"], sample["value"] + ) + + assert len(calls) == 7 + + async def test_batch_persists_every_row_with_correct_values(self, monitor): + await monitor.record_metrics([ + {"component": "system", "metric_name": "cpu_usage_percent", + "value": 42.5, "unit": "%"}, + {"component": "process", "metric_name": "threads_count", + "value": 8.0, "unit": "count"}, + {"component": "frontend", "metric_name": "bundle_load_time", + "value": 1500.0, "unit": "ms", "tags": {"page": "home"}}, + ]) + + conn = sqlite3.connect(monitor.db_path) + try: + rows = conn.execute( + "SELECT component, metric_name, value, unit, tags " + "FROM performance_metrics ORDER BY metric_name" + ).fetchall() + finally: + conn.close() + + assert len(rows) == 3 + by_name = {r[1]: r for r in rows} + assert by_name["cpu_usage_percent"][0] == "system" + assert by_name["cpu_usage_percent"][2] == 42.5 + assert by_name["cpu_usage_percent"][3] == "%" + assert by_name["threads_count"][0] == "process" + assert by_name["threads_count"][2] == 8.0 + assert by_name["bundle_load_time"][2] == 1500.0 + assert '"page": "home"' in by_name["bundle_load_time"][4] + + async def test_batch_matches_serial_buffer_and_collections(self, tmp_path): + """Same inputs through both paths must leave identical in-memory state.""" + samples = [ + {"component": "video", "metric_name": "video_processing_time", + "value": 1200.0, "unit": "ms"}, + {"component": "db", "metric_name": "database_query_time", + "value": 35.0, "unit": "ms"}, + {"component": "api", "metric_name": "api_response_time", + "value": 250.0, "unit": "ms"}, + {"component": "system", "metric_name": "cpu_usage_percent", + "value": 10.0, "unit": "%"}, + ] + + serial = self._quiesced(tmp_path / "serial.db") + for s in samples: + await serial.record_metric( + s["component"], s["metric_name"], s["value"], s["unit"] + ) + + batched = self._quiesced(tmp_path / "batched.db") + await batched.record_metrics(samples) + + assert list(batched.video_processing_times) == list(serial.video_processing_times) + assert list(batched.database_query_times) == list(serial.database_query_times) + assert list(batched.api_response_times) == list(serial.api_response_times) + + assert len(batched.metrics_buffer) == len(serial.metrics_buffer) + for got, want in zip(batched.metrics_buffer, serial.metrics_buffer): + assert (got.component, got.metric_name, got.value, got.unit) == ( + want.component, want.metric_name, want.value, want.unit + ) + + async def test_empty_batch_does_no_database_work(self, monitor): + calls: list[int] = [] + with patch.object(perf_mod.sqlite3, "connect", self._counting_connect(calls)): + await monitor.record_metrics([]) + + assert calls == [] + assert len(monitor.metrics_buffer) == 0 + + async def test_batch_evaluates_thresholds_for_every_metric(self, monitor): + """A breach in one metric must not mask a breach in another.""" + await monitor.record_metrics([ + {"component": "system", "metric_name": "cpu_usage_percent", + "value": 99.0, "unit": "%"}, + {"component": "system", "metric_name": "memory_usage_percent", + "value": 95.0, "unit": "%"}, + ]) + + assert len(monitor.active_alerts) == 2 + + async def test_batch_swallows_database_errors(self, monitor): + """Metric recording is best-effort; a dead disk must not fail callers.""" + def _boom(*_args, **_kwargs): + raise sqlite3.OperationalError("disk I/O error") + + with patch.object(perf_mod.sqlite3, "connect", _boom): + await monitor.record_metrics(self._samples(3)) # must not raise + + async def test_system_resource_cycle_uses_a_single_connection( + self, monitor, monkeypatch + ): + """End-to-end proof for the background loop's 30-second cycle.""" + import types + + fake_psutil = types.SimpleNamespace( + cpu_percent=lambda interval=None: 45.0, + virtual_memory=lambda: types.SimpleNamespace( + percent=60.0, available=3 * 1024**3 + ), + disk_usage=lambda path: types.SimpleNamespace( + used=60 * 1024**3, total=100 * 1024**3 + ), + Process=lambda: types.SimpleNamespace( + memory_info=lambda: types.SimpleNamespace(rss=200 * 1024**2), + cpu_percent=lambda: 10.0, + num_threads=lambda: 8, + ), + ) + monkeypatch.setattr(perf_mod, "psutil", fake_psutil) + + calls: list[int] = [] + with patch.object(perf_mod.sqlite3, "connect", self._counting_connect(calls)): + await monitor._monitor_system_resources() + + assert len(calls) == 1, ( + f"one monitoring cycle should be one write, got {len(calls)}" + ) + assert len(monitor.metrics_buffer) == 7 + + async def test_system_resource_cycle_survives_process_metrics_failure( + self, monitor, monkeypatch + ): + """Process metrics are optional; losing them must not lose the rest.""" + import types + + def _no_process(): + raise RuntimeError("process introspection unavailable") + + fake_psutil = types.SimpleNamespace( + cpu_percent=lambda interval=None: 45.0, + virtual_memory=lambda: types.SimpleNamespace( + percent=60.0, available=3 * 1024**3 + ), + disk_usage=lambda path: types.SimpleNamespace( + used=60 * 1024**3, total=100 * 1024**3 + ), + Process=_no_process, + ) + monkeypatch.setattr(perf_mod, "psutil", fake_psutil) + + await monitor._monitor_system_resources() + + # The four system-level metrics still land even though the process + # block raised partway through. + assert len(monitor.metrics_buffer) == 4 + names = {m.metric_name for m in monitor.metrics_buffer} + assert "cpu_usage_percent" in names + assert "disk_usage_percent" in names From d85abd8667970a7996c37647a89636772b16675b Mon Sep 17 00:00:00 2001 From: Hayden <154503486+groupthinking@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:29:49 -0500 Subject: [PATCH 2/4] test: patch module resolved from class, not stale alias Three tests failed only under the full suite with PYTHONPATH=src. Root cause: the tests patched `perf_mod`, a module-level alias that can bind to a *different* module object than the one PerformanceMonitor's methods read their globals from. This file's preamble re-imports the module, and CI's PYTHONPATH=src lets the package resolve under a second name, so `monkeypatch.setattr(perf_mod, "psutil", fake)` silently no-ops. The real psutil then ran, and on a loaded CI machine cpu/memory exceeded the 80% warning thresholds, firing an alert. `_store_alert` opens its own sqlite3 connection, so the "one cycle, one connection" assertion saw 2, and the process-metrics test saw all 7 samples instead of 4. Resolve the target as sys.modules[PerformanceMonitor.__module__] so the patch lands regardless of import identity, and clear the metrics buffer before each cycle so leftover samples cannot bleed across tests. Separately, the report endpoint now batches through `record_metrics`; its error-path test still patched the singular `record_metric`, which is inert and let the request succeed with 200 instead of 500. Repointed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/unit/test_performance_monitor.py | 22 +++++++++++++++++++--- tests/unit/test_v1_router_extended.py | 10 ++++++++-- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/tests/unit/test_performance_monitor.py b/tests/unit/test_performance_monitor.py index 478ba9f11..4ceed2c76 100644 --- a/tests/unit/test_performance_monitor.py +++ b/tests/unit/test_performance_monitor.py @@ -1190,6 +1190,20 @@ def _wrapped(*args, **kwargs): return _wrapped + @staticmethod + def _impl_module(): + """The module whose globals ``PerformanceMonitor`` methods actually read. + + This file's preamble deliberately re-imports the performance monitor, + and CI runs with ``PYTHONPATH=src`` so the package can also resolve + under a second name. Either can leave the module-level ``perf_mod`` + alias bound to a *different* module object than the one the class was + defined in, at which point ``monkeypatch.setattr(perf_mod, ...)`` + silently no-ops and the real ``psutil`` runs instead of the fake. + Resolving from the class is correct under every import identity. + """ + return sys.modules[PerformanceMonitor.__module__] + @staticmethod def _samples(n): return [ @@ -1339,10 +1353,11 @@ async def test_system_resource_cycle_uses_a_single_connection( num_threads=lambda: 8, ), ) - monkeypatch.setattr(perf_mod, "psutil", fake_psutil) + monkeypatch.setattr(self._impl_module(), "psutil", fake_psutil) + monitor.metrics_buffer.clear() calls: list[int] = [] - with patch.object(perf_mod.sqlite3, "connect", self._counting_connect(calls)): + with patch.object(self._impl_module().sqlite3, "connect", self._counting_connect(calls)): await monitor._monitor_system_resources() assert len(calls) == 1, ( @@ -1369,8 +1384,9 @@ def _no_process(): ), Process=_no_process, ) - monkeypatch.setattr(perf_mod, "psutil", fake_psutil) + monkeypatch.setattr(self._impl_module(), "psutil", fake_psutil) + monitor.metrics_buffer.clear() await monitor._monitor_system_resources() # The four system-level metrics still land even though the process diff --git a/tests/unit/test_v1_router_extended.py b/tests/unit/test_v1_router_extended.py index bb206e505..a6648362f 100644 --- a/tests/unit/test_v1_router_extended.py +++ b/tests/unit/test_v1_router_extended.py @@ -2180,10 +2180,16 @@ def test_performance_alert_error(self, client): assert resp.status_code == 500 def test_performance_report_error(self, client): - """record_metric raises → 500.""" + """record_metrics raises → 500. + + The report endpoint ingests the whole payload in one batched + ``record_metrics`` call, so the failure has to be injected there; + patching the singular ``record_metric`` is inert and the request + would succeed with a 200. + """ with patch.object( router_module.performance_monitor, - "record_metric", + "record_metrics", new_callable=AsyncMock, side_effect=RuntimeError("monitor error"), ): From 69f277915d27dd289cbd221bd11745f518fdeff5 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:45:14 -0500 Subject: [PATCH 3/4] test: pin batching contract in test_performance_report Review follow-up. The happy-path report test patched the singular `record_metric`, but the endpoint calls `record_metrics`. The patch was therefore inert: the test still returned 200 and still asserted the count, while the request performed a live in-process SQLite write. It passed for the wrong reason and had silently lost its isolation. Repoint the patch to `record_metrics` and assert the batched call shape -- one await for the whole report, carrying both samples with the expected names, values and component. The await-count assertion is the substantive part. It pins the contract this endpoint exists to provide: a regression to one write per metric would preserve both the 200 and `metrics_recorded`, so nothing else in the suite would notice. Prove-failed by reverting the patch target to `record_metric`: the request still returns 200 and the new assertion fails `assert 0 == 1`, confirming the guard catches exactly the defect it was written for. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/unit/test_v1_router_extended.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_v1_router_extended.py b/tests/unit/test_v1_router_extended.py index a6648362f..49dd3cb28 100644 --- a/tests/unit/test_v1_router_extended.py +++ b/tests/unit/test_v1_router_extended.py @@ -668,6 +668,18 @@ def test_performance_alert_non_numeric(self, client): assert resp.status_code == 200 def test_performance_report(self, client): + """Report ingest batches every sample into one ``record_metrics`` call. + + Patches the plural ``record_metrics`` because that is what the endpoint + calls. Patching the singular ``record_metric`` here would be inert and + let the request perform a live in-process SQLite write, so the test + would still pass while silently losing its isolation. + + The await-count assertion is the point: it pins the batching contract + this endpoint exists to provide. A regression back to one write per + metric would keep the 200 and the count, and only this assertion would + catch it. + """ payload = { "metrics": { "lcp": {"current": 1200, "unit": "ms"}, @@ -675,14 +687,21 @@ def test_performance_report(self, client): } } with patch.object( - router_module.performance_monitor, "record_metric", new_callable=AsyncMock - ): + router_module.performance_monitor, "record_metrics", new_callable=AsyncMock + ) as mock_record: resp = client.post("/api/v1/performance/report", json=payload) assert resp.status_code == 200 data = resp.json() assert data["status"] == "ok" assert data["metrics_recorded"] == 2 + # Exactly one write for the whole report, carrying both samples. + assert mock_record.await_count == 1 + (samples,) = mock_record.await_args.args + assert [s["metric_name"] for s in samples] == ["lcp", "fid"] + assert [s["value"] for s in samples] == [1200.0, 30.0] + assert {s["component"] for s in samples} == {"frontend"} + def test_performance_report_empty(self, client): resp = client.post("/api/v1/performance/report", json={}) assert resp.status_code == 200 From 3f15c27645c218ac8b2a6e0ea2d5a8ee5ad1bd10 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 04:53:35 +0000 Subject: [PATCH 4/4] fix(perf): stamp each batched metric with its own timestamp record_metrics computed a single datetime.now() for the whole batch, so every row shared one identical timestamp. The serial record_metric path stamps each metric at construction time, so batching silently diverged from the promised serial semantics and erased per-sample ordering for callers that submit genuinely distinct samples. Move the now() call into the per-record comprehension so the batch path matches record_metric exactly. Addresses Copilot reviewer feedback on PR #1341. Verified: 128/128 in tests/unit/test_performance_monitor.py, including the serial-vs-batched parity test. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DduoiyZdhb27Qb7wWoEq45 --- .../backend/services/performance_monitor.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/youtube_extension/backend/services/performance_monitor.py b/src/youtube_extension/backend/services/performance_monitor.py index 6d3d1d28e..1961f68a9 100644 --- a/src/youtube_extension/backend/services/performance_monitor.py +++ b/src/youtube_extension/backend/services/performance_monitor.py @@ -293,13 +293,17 @@ async def record_metrics(self, metrics: Sequence[Mapping[str, Any]]) -> None: return try: - now = datetime.now(timezone.utc) + # Stamp each record with its own ``datetime.now`` exactly as the + # serial ``record_metric`` does. A single shared ``now`` for the + # whole batch would give every row an identical timestamp, which + # diverges from the promised serial semantics and erases per-sample + # ordering for callers that submit genuinely distinct samples. records = [ PerformanceMetric( component=entry["component"], metric_name=entry["metric_name"], value=float(entry["value"]), - timestamp=entry.get("timestamp") or now, + timestamp=entry.get("timestamp") or datetime.now(timezone.utc), unit=entry.get("unit", "ms"), tags=entry.get("tags") or {}, )