raylim Claude Opus 4.6 commited on
Commit
cb59bc2
·
1 Parent(s): 4e6b8c4

fix: address PR review feedback for HF user telemetry

Browse files

- Replace silent `except Exception: pass` with proper logging and
narrowed catch (binascii.Error, json.JSONDecodeError, etc.)
- Add IS_HF_SPACES to hardware.py as source of truth for HF Spaces
detection, use consistently in analysis.py and ui/app.py
- Update privacy docs to reflect intentional raw username storage
- Add tracker-level, event dataclass, and edge case tests for new
is_logged_in/hf_username fields

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

src/mosaic/analysis.py CHANGED
@@ -26,6 +26,7 @@ from mosaic.telemetry import extract_user_info
26
  # Import centralized hardware detection
27
  from mosaic.hardware import (
28
  spaces,
 
29
  IS_ZEROGPU,
30
  IS_T4_GPU,
31
  GPU_TYPE,
@@ -808,7 +809,7 @@ def analyze_slide(
808
  raise gr.Error("Please upload a slide.")
809
 
810
  # Extract user info for telemetry (HF Spaces only)
811
- user_info = extract_user_info(request, IS_ZEROGPU)
812
 
813
  # Initialize telemetry for resource tracking
814
  slide_start_time = time_module.time()
 
26
  # Import centralized hardware detection
27
  from mosaic.hardware import (
28
  spaces,
29
+ IS_HF_SPACES,
30
  IS_ZEROGPU,
31
  IS_T4_GPU,
32
  GPU_TYPE,
 
809
  raise gr.Error("Please upload a slide.")
810
 
811
  # Extract user info for telemetry (HF Spaces only)
812
+ user_info = extract_user_info(request, IS_HF_SPACES)
813
 
814
  # Initialize telemetry for resource tracking
815
  slide_start_time = time_module.time()
src/mosaic/hardware.py CHANGED
@@ -8,6 +8,9 @@ import os
8
  import torch
9
  from loguru import logger
10
 
 
 
 
11
  # Detect HuggingFace Spaces ZeroGPU environment
12
  try:
13
  import spaces
@@ -95,6 +98,7 @@ def get_gpu_metrics() -> dict:
95
  __all__ = [
96
  "spaces",
97
  "HAS_SPACES",
 
98
  "IS_ZEROGPU",
99
  "IS_T4_GPU",
100
  "GPU_NAME",
 
8
  import torch
9
  from loguru import logger
10
 
11
+ # Detect HuggingFace Spaces environment
12
+ IS_HF_SPACES = bool(os.environ.get("SPACE_ID"))
13
+
14
  # Detect HuggingFace Spaces ZeroGPU environment
15
  try:
16
  import spaces
 
98
  __all__ = [
99
  "spaces",
100
  "HAS_SPACES",
101
+ "IS_HF_SPACES",
102
  "IS_ZEROGPU",
103
  "IS_T4_GPU",
104
  "GPU_NAME",
src/mosaic/telemetry/__init__.py CHANGED
@@ -8,7 +8,7 @@ This module provides lightweight telemetry for the Mosaic HuggingFace app to tra
8
  Key features:
9
  - Gradio-only: No telemetry for CLI batch processing
10
  - No external dependencies: Uses stdlib only (json, dataclasses)
11
- - Privacy-first: Session IDs hashed, no file paths or PII stored
12
  - File-based storage: JSONL format with daily rotation
13
  - HF Spaces compatible: Uses /data persistent storage when available
14
 
 
8
  Key features:
9
  - Gradio-only: No telemetry for CLI batch processing
10
  - No external dependencies: Uses stdlib only (json, dataclasses)
11
+ - Privacy-first: Session IDs hashed, no file paths stored. HF usernames recorded for usage tracking.
12
  - File-based storage: JSONL format with daily rotation
13
  - HF Spaces compatible: Uses /data persistent storage when available
14
 
src/mosaic/telemetry/tracker.py CHANGED
@@ -232,7 +232,7 @@ class TelemetryTracker:
232
  duration_sec: Analysis duration (for analysis_complete only)
233
  success: Whether analysis succeeded (for analysis_complete only)
234
  is_logged_in: True if HF user logged in
235
- hf_username: Raw HF username (HF Spaces only)
236
  """
237
  if not self._is_enabled():
238
  return
@@ -298,7 +298,7 @@ class TelemetryTracker:
298
  gpu_type: GPU type string
299
  peak_gpu_memory_gb: Peak GPU memory usage in GB
300
  is_logged_in: True if HF user logged in
301
- hf_username: Raw HF username (HF Spaces only)
302
  """
303
  if not self._is_enabled():
304
  return
@@ -349,7 +349,7 @@ class TelemetryTracker:
349
  slide_count: Number of slides if known
350
  gpu_type: GPU type string
351
  is_logged_in: True if HF user logged in
352
- hf_username: Raw HF username (HF Spaces only)
353
  """
354
  if not self._is_enabled():
355
  return
 
232
  duration_sec: Analysis duration (for analysis_complete only)
233
  success: Whether analysis succeeded (for analysis_complete only)
234
  is_logged_in: True if HF user logged in
235
+ hf_username: HF username (HF Spaces only)
236
  """
237
  if not self._is_enabled():
238
  return
 
298
  gpu_type: GPU type string
299
  peak_gpu_memory_gb: Peak GPU memory usage in GB
300
  is_logged_in: True if HF user logged in
301
+ hf_username: HF username (HF Spaces only)
302
  """
303
  if not self._is_enabled():
304
  return
 
349
  slide_count: Number of slides if known
350
  gpu_type: GPU type string
351
  is_logged_in: True if HF user logged in
352
+ hf_username: HF username (HF Spaces only)
353
  """
354
  if not self._is_enabled():
355
  return
src/mosaic/telemetry/utils.py CHANGED
@@ -9,6 +9,7 @@ This module provides helper utilities:
9
  """
10
 
11
  import base64
 
12
  import hashlib
13
  import json
14
  import re
@@ -17,6 +18,8 @@ from contextlib import contextmanager
17
  from dataclasses import dataclass
18
  from typing import Dict, Optional
19
 
 
 
20
 
21
  @contextmanager
22
  def StageTimer(stage_name: str, timings: Dict[str, float]):
@@ -130,7 +133,7 @@ def extract_user_info(request, is_hf_spaces: bool = False) -> UserInfo:
130
  UserInfo with is_logged_in and username (or defaults if extraction fails)
131
 
132
  Example:
133
- user_info = extract_user_info(request, IS_ZEROGPU)
134
  if user_info.is_logged_in:
135
  print(f"User: {user_info.username}")
136
  """
@@ -171,9 +174,9 @@ def extract_user_info(request, is_hf_spaces: bool = False) -> UserInfo:
171
  username = token_data["onBehalfOf"]["user"]
172
  return UserInfo(is_logged_in=True, username=username)
173
 
174
- except Exception:
175
- # Silently fail and return default UserInfo
176
- # (Logging happens at call site if needed)
177
- pass
178
 
179
  return UserInfo()
 
9
  """
10
 
11
  import base64
12
+ import binascii
13
  import hashlib
14
  import json
15
  import re
 
18
  from dataclasses import dataclass
19
  from typing import Dict, Optional
20
 
21
+ from loguru import logger
22
+
23
 
24
  @contextmanager
25
  def StageTimer(stage_name: str, timings: Dict[str, float]):
 
133
  UserInfo with is_logged_in and username (or defaults if extraction fails)
134
 
135
  Example:
136
+ user_info = extract_user_info(request, IS_HF_SPACES)
137
  if user_info.is_logged_in:
138
  print(f"User: {user_info.username}")
139
  """
 
174
  username = token_data["onBehalfOf"]["user"]
175
  return UserInfo(is_logged_in=True, username=username)
176
 
177
+ except (json.JSONDecodeError, binascii.Error, UnicodeDecodeError, ValueError) as e:
178
+ logger.warning(f"Failed to decode JWT token from request: {e}")
179
+ except Exception as e:
180
+ logger.error(f"Unexpected error extracting user info: {e}")
181
 
182
  return UserInfo()
src/mosaic/ui/app.py CHANGED
@@ -33,7 +33,7 @@ from mosaic.ui.utils import (
33
  )
34
  from mosaic.analysis import analyze_slide
35
  from mosaic.model_manager import load_all_models
36
- from mosaic.hardware import DEFAULT_CONCURRENCY_LIMIT, IS_T4_GPU, GPU_TYPE
37
  from mosaic.telemetry import extract_user_info
38
  from mosaic.tcga import (
39
  fetch_slide,
@@ -218,7 +218,7 @@ def analyze_slides(
218
  session_hash = request.session_hash if request else None
219
 
220
  # Extract user info for telemetry (HF Spaces only)
221
- user_info = extract_user_info(request, tracker.config.is_hf_spaces)
222
 
223
  # Wait for core models download to complete (Paladin models can continue in background)
224
  if _model_download_thread is not None and not _core_models_complete:
 
33
  )
34
  from mosaic.analysis import analyze_slide
35
  from mosaic.model_manager import load_all_models
36
+ from mosaic.hardware import DEFAULT_CONCURRENCY_LIMIT, IS_HF_SPACES, IS_T4_GPU, GPU_TYPE
37
  from mosaic.telemetry import extract_user_info
38
  from mosaic.tcga import (
39
  fetch_slide,
 
218
  session_hash = request.session_hash if request else None
219
 
220
  # Extract user info for telemetry (HF Spaces only)
221
+ user_info = extract_user_info(request, IS_HF_SPACES)
222
 
223
  # Wait for core models download to complete (Paladin models can continue in background)
224
  if _model_download_thread is not None and not _core_models_complete:
tests/telemetry/test_events.py CHANGED
@@ -108,6 +108,37 @@ class TestUsageEvent:
108
  assert data["slide_count"] == 3
109
  assert data["session_hash"] is None
110
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
 
112
  class TestResourceEvent:
113
  """Tests for ResourceEvent."""
@@ -147,6 +178,20 @@ class TestResourceEvent:
147
  assert data["total_duration_sec"] == 100.0
148
  assert data["tile_count"] == 500
149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
 
151
  class TestFailureEvent:
152
  """Tests for FailureEvent."""
@@ -190,6 +235,20 @@ class TestFailureEvent:
190
  assert data["error_type"] == "MemoryError"
191
  assert data["error_stage"] == "ctranspath"
192
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
 
194
  class TestEventIdGeneration:
195
  """Tests for event ID generation."""
 
108
  assert data["slide_count"] == 3
109
  assert data["session_hash"] is None
110
 
111
+ def test_user_info_fields(self):
112
+ """Test is_logged_in and hf_username fields in UsageEvent."""
113
+ event = UsageEvent(
114
+ event_type="analysis_start",
115
+ analysis_id="test-123",
116
+ session_hash=None,
117
+ slide_count=1,
118
+ is_logged_in=True,
119
+ hf_username="testuser",
120
+ )
121
+
122
+ assert event.is_logged_in is True
123
+ assert event.hf_username == "testuser"
124
+
125
+ data = event.to_dict()
126
+ assert data["is_logged_in"] is True
127
+ assert data["hf_username"] == "testuser"
128
+
129
+ def test_user_info_fields_default_none(self):
130
+ """Test that user info fields default to None in to_dict()."""
131
+ event = UsageEvent(
132
+ event_type="analysis_start",
133
+ analysis_id="test-123",
134
+ session_hash=None,
135
+ slide_count=1,
136
+ )
137
+
138
+ data = event.to_dict()
139
+ assert data["is_logged_in"] is None
140
+ assert data["hf_username"] is None
141
+
142
 
143
  class TestResourceEvent:
144
  """Tests for ResourceEvent."""
 
178
  assert data["total_duration_sec"] == 100.0
179
  assert data["tile_count"] == 500
180
 
181
+ def test_user_info_fields(self):
182
+ """Test is_logged_in and hf_username fields in ResourceEvent."""
183
+ event = ResourceEvent(
184
+ analysis_id="test-123",
185
+ session_hash=None,
186
+ total_duration_sec=100.0,
187
+ is_logged_in=True,
188
+ hf_username="testuser",
189
+ )
190
+
191
+ data = event.to_dict()
192
+ assert data["is_logged_in"] is True
193
+ assert data["hf_username"] == "testuser"
194
+
195
 
196
  class TestFailureEvent:
197
  """Tests for FailureEvent."""
 
235
  assert data["error_type"] == "MemoryError"
236
  assert data["error_stage"] == "ctranspath"
237
 
238
+ def test_user_info_fields(self):
239
+ """Test is_logged_in and hf_username fields in FailureEvent."""
240
+ event = FailureEvent(
241
+ error_type="ValueError",
242
+ error_message="test error",
243
+ error_stage="upload",
244
+ is_logged_in=False,
245
+ hf_username=None,
246
+ )
247
+
248
+ data = event.to_dict()
249
+ assert data["is_logged_in"] is False
250
+ assert data["hf_username"] is None
251
+
252
 
253
  class TestEventIdGeneration:
254
  """Tests for event ID generation."""
tests/telemetry/test_tracker.py CHANGED
@@ -137,6 +137,23 @@ class TestUsageEvents:
137
  assert event["session_hash"] is not None
138
  assert event["session_hash"] != "abc123"
139
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  def test_log_analysis_complete(self, tracker, temp_dir):
141
  """Test logging analysis complete event."""
142
  tracker.log_app_start()
@@ -206,6 +223,22 @@ class TestResourceEvents:
206
  assert event["tile_count"] == 1000
207
  assert event["peak_gpu_memory_gb"] == 12.5
208
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
 
210
  class TestFailureEvents:
211
  """Tests for failure event logging."""
@@ -231,6 +264,23 @@ class TestFailureEvents:
231
  assert event["error_stage"] == "upload"
232
  assert event["analysis_id"] == "test-123"
233
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
  def test_error_message_sanitized(self, tracker, temp_dir):
235
  """Test that error messages are sanitized."""
236
  tracker.log_failure_event(
 
137
  assert event["session_hash"] is not None
138
  assert event["session_hash"] != "abc123"
139
 
140
+ def test_log_usage_event_with_user_info(self, tracker, temp_dir):
141
+ """Test that is_logged_in and hf_username are persisted in usage events."""
142
+ tracker.log_usage_event(
143
+ event_type="analysis_start",
144
+ analysis_id="test-user-info",
145
+ slide_count=1,
146
+ is_logged_in=True,
147
+ hf_username="testuser",
148
+ )
149
+
150
+ usage_files = list((temp_dir / "daily").glob("usage_*.jsonl"))
151
+ with open(usage_files[0]) as f:
152
+ event = json.loads(f.read().strip())
153
+
154
+ assert event["is_logged_in"] is True
155
+ assert event["hf_username"] == "testuser"
156
+
157
  def test_log_analysis_complete(self, tracker, temp_dir):
158
  """Test logging analysis complete event."""
159
  tracker.log_app_start()
 
223
  assert event["tile_count"] == 1000
224
  assert event["peak_gpu_memory_gb"] == 12.5
225
 
226
+ def test_log_resource_event_with_user_info(self, tracker, temp_dir):
227
+ """Test that is_logged_in and hf_username are persisted in resource events."""
228
+ tracker.log_resource_event(
229
+ analysis_id="test-user-info",
230
+ total_duration_sec=60.0,
231
+ is_logged_in=True,
232
+ hf_username="testuser",
233
+ )
234
+
235
+ resource_files = list((temp_dir / "daily").glob("resource_*.jsonl"))
236
+ with open(resource_files[0]) as f:
237
+ event = json.loads(f.read().strip())
238
+
239
+ assert event["is_logged_in"] is True
240
+ assert event["hf_username"] == "testuser"
241
+
242
 
243
  class TestFailureEvents:
244
  """Tests for failure event logging."""
 
264
  assert event["error_stage"] == "upload"
265
  assert event["analysis_id"] == "test-123"
266
 
267
+ def test_log_failure_event_with_user_info(self, tracker, temp_dir):
268
+ """Test that is_logged_in and hf_username are persisted in failure events."""
269
+ tracker.log_failure_event(
270
+ error_type="ValueError",
271
+ error_message="test error",
272
+ error_stage="upload",
273
+ is_logged_in=False,
274
+ hf_username=None,
275
+ )
276
+
277
+ failure_files = list((temp_dir / "daily").glob("failure_*.jsonl"))
278
+ with open(failure_files[0]) as f:
279
+ event = json.loads(f.read().strip())
280
+
281
+ assert event["is_logged_in"] is False
282
+ assert event["hf_username"] is None
283
+
284
  def test_error_message_sanitized(self, tracker, temp_dir):
285
  """Test that error messages are sanitized."""
286
  tracker.log_failure_event(
tests/telemetry/test_utils.py CHANGED
@@ -302,3 +302,36 @@ class TestExtractUserInfo:
302
 
303
  assert user_info.is_logged_in is True
304
  assert user_info.username == "user-name_123"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302
 
303
  assert user_info.is_logged_in is True
304
  assert user_info.username == "user-name_123"
305
+
306
+ def test_extract_user_info_jwt_on_behalf_of_without_user(self):
307
+ """Test extraction when onBehalfOf exists but user key is missing."""
308
+ header = {"alg": "HS256", "typ": "JWT"}
309
+ payload = {"onBehalfOf": {"role": "admin"}} # No "user" key
310
+
311
+ header_b64 = base64.urlsafe_b64encode(json.dumps(header).encode()).decode()
312
+ payload_b64 = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode()
313
+ header_b64 = header_b64.rstrip("=")
314
+ payload_b64 = payload_b64.rstrip("=")
315
+
316
+ token = f"{header_b64}.{payload_b64}.fake_sig"
317
+ referer = f"https://huggingface.co/spaces/test/app?__sign={token}"
318
+
319
+ request = self._create_mock_request(referer)
320
+ user_info = extract_user_info(request, is_hf_spaces=True)
321
+
322
+ assert user_info.is_logged_in is False
323
+ assert user_info.username is None
324
+
325
+ def test_extract_user_info_malformed_jwt_logs_warning(self, caplog):
326
+ """Test that malformed JWT triggers a warning log."""
327
+ import logging
328
+
329
+ referer = "https://huggingface.co/spaces/test/app?__sign=invalid.jwt.token"
330
+ request = self._create_mock_request(referer)
331
+
332
+ with caplog.at_level(logging.WARNING):
333
+ user_info = extract_user_info(request, is_hf_spaces=True)
334
+
335
+ assert user_info.is_logged_in is False
336
+ # loguru propagates to standard logging when caplog is used with propagate=True
337
+ # The function should log a warning about JWT decode failure