|
9 | 9 | """ |
10 | 10 |
|
11 | 11 | import os |
| 12 | +import threading |
12 | 13 | from pathlib import Path |
13 | 14 | from unittest import mock |
14 | 15 |
|
|
17 | 18 | from promptfoo.telemetry import ( |
18 | 19 | _get_config_dir, |
19 | 20 | _get_env_bool, |
| 21 | + _get_telemetry, |
20 | 22 | _get_user_email, |
21 | 23 | _get_user_id, |
22 | 24 | _is_ci, |
23 | 25 | _read_global_config, |
24 | 26 | _Telemetry, |
| 27 | + _telemetry_lock, |
25 | 28 | _write_global_config, |
26 | 29 | record_wrapper_used, |
27 | 30 | ) |
@@ -268,6 +271,23 @@ def test_record_initializes_client(self, tmp_path: Path) -> None: |
268 | 271 | assert telemetry._client is mock_client |
269 | 272 | mock_client.capture.assert_called_once() |
270 | 273 |
|
| 274 | + def test_initialization_reads_global_config_once(self) -> None: |
| 275 | + """Initialization shares one config read across user identity lookups.""" |
| 276 | + config = {"id": "test-user-id", "account": {"email": "test@example.com"}} |
| 277 | + |
| 278 | + with ( |
| 279 | + mock.patch.dict(os.environ, {}, clear=True), |
| 280 | + mock.patch("promptfoo.telemetry._read_global_config", return_value=config) as mock_read_config, |
| 281 | + mock.patch("promptfoo.telemetry.Posthog") as mock_posthog, |
| 282 | + ): |
| 283 | + telemetry = _Telemetry() |
| 284 | + telemetry._ensure_initialized() |
| 285 | + |
| 286 | + mock_read_config.assert_called_once_with() |
| 287 | + assert telemetry._user_id == "test-user-id" |
| 288 | + assert telemetry._email == "test@example.com" |
| 289 | + mock_posthog.assert_called_once() |
| 290 | + |
271 | 291 | def test_record_enriches_properties(self, tmp_path: Path) -> None: |
272 | 292 | """Test record adds enriched properties.""" |
273 | 293 | config_file = tmp_path / "promptfoo.yaml" |
@@ -471,3 +491,38 @@ def test_record_wrapper_used_disabled(self, monkeypatch: pytest.MonkeyPatch) -> |
471 | 491 | with mock.patch("promptfoo.telemetry._telemetry", None): |
472 | 492 | # Should not raise or make any calls |
473 | 493 | record_wrapper_used("global") |
| 494 | + |
| 495 | + def test_get_telemetry_guards_singleton_initialization_with_lock(self) -> None: |
| 496 | + """Singleton construction waits on its lock and registers shutdown once.""" |
| 497 | + started = threading.Event() |
| 498 | + finished = threading.Event() |
| 499 | + instance = mock.Mock(spec=_Telemetry) |
| 500 | + results: list[_Telemetry] = [] |
| 501 | + |
| 502 | + def initialize() -> None: |
| 503 | + started.set() |
| 504 | + results.append(_get_telemetry()) |
| 505 | + finished.set() |
| 506 | + |
| 507 | + with ( |
| 508 | + mock.patch("promptfoo.telemetry._telemetry", None), |
| 509 | + mock.patch("promptfoo.telemetry._Telemetry", return_value=instance) as mock_telemetry, |
| 510 | + mock.patch("promptfoo.telemetry.atexit.register") as mock_register, |
| 511 | + ): |
| 512 | + _telemetry_lock.acquire() |
| 513 | + try: |
| 514 | + worker = threading.Thread(target=initialize) |
| 515 | + worker.start() |
| 516 | + assert started.wait(timeout=1) |
| 517 | + assert finished.wait(timeout=0.05) is False |
| 518 | + mock_telemetry.assert_not_called() |
| 519 | + finally: |
| 520 | + _telemetry_lock.release() |
| 521 | + |
| 522 | + worker.join(timeout=1) |
| 523 | + assert worker.is_alive() is False |
| 524 | + |
| 525 | + assert results == [instance] |
| 526 | + assert _get_telemetry() is instance |
| 527 | + mock_telemetry.assert_called_once_with() |
| 528 | + mock_register.assert_called_once_with(instance.shutdown) |
0 commit comments