File size: 6,532 Bytes
13170f7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
"""Lightweight usage event logger for HF Space demos.

Appends JSONL events to a local folder, which huggingface_hub's CommitScheduler
periodically pushes to a private HF Dataset repo. Auto-enables on HF Spaces;
disabled locally unless USAGE_ANALYTICS=1.
"""

from __future__ import annotations

import io
import json
import os
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING

from log_utils import setup_logger

if TYPE_CHECKING:
    from huggingface_hub import CommitInfo

logger = setup_logger("UsageTracker")


def _make_cleanup_scheduler(active_log_file: Path, **kwargs: object) -> object:
    """Build a CommitScheduler that deletes pushed files (except the active one).

    Prevents stale files from being re-uploaded under a different ``path_in_repo``
    if the demo name changes between process restarts.
    """
    from huggingface_hub import CommitScheduler

    class _Cleanup(CommitScheduler):
        def __init__(self, active_file: Path, **kw: object) -> None:
            self._active_file = active_file.resolve()
            super().__init__(**kw)

        def push_to_hub(self) -> CommitInfo | None:
            result = super().push_to_hub()
            if result is None:
                return None
            with self.lock:
                for path in sorted(self.folder_path.glob("**/*")):
                    if path.is_file() and path.resolve() != self._active_file:
                        path.unlink(missing_ok=True)
                        self.last_uploaded.pop(path, None)
            return result

    return _Cleanup(active_file=active_log_file, **kwargs)


class UsageTracker:
    """Append-only usage event logger backed by a private HF Dataset repo.

    Events are written as JSONL to a local folder. A background thread
    (CommitScheduler) batches and pushes them to the dataset repo every
    ``push_interval_minutes``. Thread-safe, never raises from ``log()``.

    Args:
        repo_id: Target HF dataset repo
            (e.g. ``"LatticeSemi/STAGING-Demo-Analytics-v1.0"``).
        push_interval_minutes: How often CommitScheduler pushes to HF Hub.
        enabled: Explicit on/off. ``None`` = auto-detect (on when ``SPACE_ID``
            env var is set, off otherwise). The ``USAGE_ANALYTICS`` env var
            always takes precedence over both this parameter and ``SPACE_ID``.
    """

    def __init__(
        self,
        repo_id: str,
        push_interval_minutes: int = 10,
        enabled: bool | None = None,
    ) -> None:
        self._enabled = False
        self._log_file: Path | None = None
        self._fh: io.TextIOWrapper | None = None
        self._scheduler: object | None = None

        should_enable = self._resolve_enabled(enabled)
        if not should_enable:
            logger.info("Usage analytics: disabled")
            return

        demo_name = self._resolve_demo_name()

        try:
            log_dir = Path("usage_logs")
            log_dir.mkdir(exist_ok=True)
            self._log_file = log_dir / f"events_{uuid.uuid4().hex[:12]}.jsonl"

            token = (
                os.environ.get("ANALYTICS_TOKEN")
                or os.environ.get("HF_TOKEN")
                or os.environ.get("MODEL_ACCESS_TOKEN")
            )

            self._fh = self._log_file.open("a")
            self._scheduler = _make_cleanup_scheduler(
                active_log_file=self._log_file,
                repo_id=repo_id,
                repo_type="dataset",
                folder_path=log_dir,
                path_in_repo=demo_name,
                every=push_interval_minutes,
                token=token,
            )
            self._enabled = True
            logger.info(
                f"Usage analytics: enabled -> {repo_id}/{demo_name} "
                f"(push every {push_interval_minutes}m)"
            )
        except Exception as exc:
            logger.warning(f"Usage analytics: init failed, continuing without ({exc})")

    @property
    def enabled(self) -> bool:
        return self._enabled

    def log(self, session_hash: str, event: str, **details: str | int | float | bool) -> None:
        """Append a usage event. Thread-safe, never raises.

        Args:
            session_hash: Gradio session hash (truncated to 8 chars for privacy).
            event: Event name (e.g. ``"session_start"``, ``"video_process"``).
            **details: Arbitrary key-value pairs stored alongside the event.
        """
        if not self._enabled or self._fh is None or self._scheduler is None:
            return
        try:
            entry = {
                "ts": datetime.now(timezone.utc).isoformat(),
                "session": session_hash[:8] if session_hash else "unknown",
                "event": event,
                **details,
            }
            with self._scheduler.lock:
                self._fh.write(json.dumps(entry, separators=(",", ":")) + "\n")
                self._fh.flush()
        except Exception:
            pass

    def shutdown(self) -> None:
        """Flush and close the log file handle."""
        if self._fh is not None:
            try:
                self._fh.flush()
                self._fh.close()
            except Exception:
                pass
            self._fh = None

    @staticmethod
    def _resolve_demo_name() -> str:
        """Derive demo name from the HF Space name, or ``"debug-<folder>"`` locally.

        The folder suffix keeps eve_hmi / eve_gmod / future demos from
        sharing the same ``debug/`` path_in_repo when they're run locally.
        """
        space_id = os.environ.get("SPACE_ID", "")
        if "/" in space_id:
            return space_id.split("/", 1)[1]
        if space_id:
            return space_id
        try:
            folder = Path(sys.argv[0]).resolve().parent.name
            if folder:
                return f"debug-{folder}"
        except Exception:
            pass
        return "debug"

    @staticmethod
    def _resolve_enabled(explicit: bool | None) -> bool:
        """Determine whether analytics should be enabled.

        Priority: USAGE_ANALYTICS env var > explicit param > SPACE_ID auto-detect.
        """
        env = os.environ.get("USAGE_ANALYTICS", "").strip().lower()
        if env in ("0", "false", "off"):
            return False
        if env in ("1", "true", "on"):
            return True
        if explicit is not None:
            return explicit
        return bool(os.environ.get("SPACE_ID"))