specfact-cli 0.4.2__py3-none-any.whl → 0.6.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- specfact_cli/__init__.py +1 -1
- specfact_cli/agents/analyze_agent.py +2 -3
- specfact_cli/analyzers/__init__.py +2 -1
- specfact_cli/analyzers/ambiguity_scanner.py +601 -0
- specfact_cli/analyzers/code_analyzer.py +462 -30
- specfact_cli/analyzers/constitution_evidence_extractor.py +491 -0
- specfact_cli/analyzers/contract_extractor.py +419 -0
- specfact_cli/analyzers/control_flow_analyzer.py +281 -0
- specfact_cli/analyzers/requirement_extractor.py +337 -0
- specfact_cli/analyzers/test_pattern_extractor.py +330 -0
- specfact_cli/cli.py +151 -206
- specfact_cli/commands/constitution.py +281 -0
- specfact_cli/commands/enforce.py +42 -34
- specfact_cli/commands/import_cmd.py +481 -152
- specfact_cli/commands/init.py +224 -55
- specfact_cli/commands/plan.py +2133 -547
- specfact_cli/commands/repro.py +100 -78
- specfact_cli/commands/sync.py +701 -186
- specfact_cli/enrichers/constitution_enricher.py +765 -0
- specfact_cli/enrichers/plan_enricher.py +294 -0
- specfact_cli/importers/speckit_converter.py +364 -48
- specfact_cli/importers/speckit_scanner.py +65 -0
- specfact_cli/models/plan.py +42 -0
- specfact_cli/resources/mappings/node-async.yaml +49 -0
- specfact_cli/resources/mappings/python-async.yaml +47 -0
- specfact_cli/resources/mappings/speckit-default.yaml +82 -0
- specfact_cli/resources/prompts/specfact-enforce.md +185 -0
- specfact_cli/resources/prompts/specfact-import-from-code.md +626 -0
- specfact_cli/resources/prompts/specfact-plan-add-feature.md +188 -0
- specfact_cli/resources/prompts/specfact-plan-add-story.md +212 -0
- specfact_cli/resources/prompts/specfact-plan-compare.md +571 -0
- specfact_cli/resources/prompts/specfact-plan-init.md +531 -0
- specfact_cli/resources/prompts/specfact-plan-promote.md +352 -0
- specfact_cli/resources/prompts/specfact-plan-review.md +1276 -0
- specfact_cli/resources/prompts/specfact-plan-select.md +401 -0
- specfact_cli/resources/prompts/specfact-plan-update-feature.md +242 -0
- specfact_cli/resources/prompts/specfact-plan-update-idea.md +211 -0
- specfact_cli/resources/prompts/specfact-repro.md +268 -0
- specfact_cli/resources/prompts/specfact-sync.md +497 -0
- specfact_cli/resources/schemas/deviation.schema.json +61 -0
- specfact_cli/resources/schemas/plan.schema.json +204 -0
- specfact_cli/resources/schemas/protocol.schema.json +53 -0
- specfact_cli/resources/templates/github-action.yml.j2 +140 -0
- specfact_cli/resources/templates/plan.bundle.yaml.j2 +141 -0
- specfact_cli/resources/templates/pr-template.md.j2 +58 -0
- specfact_cli/resources/templates/protocol.yaml.j2 +24 -0
- specfact_cli/resources/templates/telemetry.yaml.example +35 -0
- specfact_cli/sync/__init__.py +10 -1
- specfact_cli/sync/watcher.py +268 -0
- specfact_cli/telemetry.py +440 -0
- specfact_cli/utils/acceptance_criteria.py +127 -0
- specfact_cli/utils/enrichment_parser.py +445 -0
- specfact_cli/utils/feature_keys.py +12 -3
- specfact_cli/utils/ide_setup.py +170 -0
- specfact_cli/utils/structure.py +179 -2
- specfact_cli/utils/yaml_utils.py +33 -0
- specfact_cli/validators/repro_checker.py +22 -1
- specfact_cli/validators/schema.py +15 -4
- specfact_cli-0.6.8.dist-info/METADATA +456 -0
- specfact_cli-0.6.8.dist-info/RECORD +99 -0
- {specfact_cli-0.4.2.dist-info → specfact_cli-0.6.8.dist-info}/entry_points.txt +1 -0
- specfact_cli-0.6.8.dist-info/licenses/LICENSE.md +202 -0
- specfact_cli-0.4.2.dist-info/METADATA +0 -370
- specfact_cli-0.4.2.dist-info/RECORD +0 -62
- specfact_cli-0.4.2.dist-info/licenses/LICENSE.md +0 -61
- {specfact_cli-0.4.2.dist-info → specfact_cli-0.6.8.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""File system watcher for continuous sync operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections import deque
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from beartype import beartype
|
|
13
|
+
from icontract import ensure, require
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
|
18
|
+
from watchdog.observers import Observer
|
|
19
|
+
else:
|
|
20
|
+
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
|
21
|
+
from watchdog.observers import Observer
|
|
22
|
+
|
|
23
|
+
from specfact_cli.utils import print_info, print_warning
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class FileChange:
|
|
28
|
+
"""Represents a file system change event."""
|
|
29
|
+
|
|
30
|
+
file_path: Path
|
|
31
|
+
change_type: str # "spec_kit", "specfact", "code"
|
|
32
|
+
event_type: str # "created", "modified", "deleted"
|
|
33
|
+
timestamp: float
|
|
34
|
+
|
|
35
|
+
@beartype
|
|
36
|
+
def __post_init__(self) -> None:
|
|
37
|
+
"""Validate file change data."""
|
|
38
|
+
if self.change_type not in ("spec_kit", "specfact", "code"):
|
|
39
|
+
msg = f"Invalid change_type: {self.change_type}. Must be spec_kit, specfact, or code"
|
|
40
|
+
raise ValueError(msg)
|
|
41
|
+
if self.event_type not in ("created", "modified", "deleted"):
|
|
42
|
+
msg = f"Invalid event_type: {self.event_type}. Must be created, modified, or deleted"
|
|
43
|
+
raise ValueError(msg)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SyncEventHandler(FileSystemEventHandler):
|
|
47
|
+
"""Event handler for file system changes during sync operations."""
|
|
48
|
+
|
|
49
|
+
@beartype
|
|
50
|
+
def __init__(self, repo_path: Path, change_queue: deque[FileChange]) -> None:
|
|
51
|
+
"""
|
|
52
|
+
Initialize event handler.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
repo_path: Path to repository root
|
|
56
|
+
change_queue: Queue to store file change events
|
|
57
|
+
"""
|
|
58
|
+
self.repo_path = Path(repo_path).resolve()
|
|
59
|
+
self.change_queue = change_queue
|
|
60
|
+
self.last_event_time: dict[str, float] = {}
|
|
61
|
+
self.debounce_interval = 0.5 # Debounce rapid file changes (500ms)
|
|
62
|
+
|
|
63
|
+
@beartype
|
|
64
|
+
@require(lambda self, event: event is not None, "Event must not be None")
|
|
65
|
+
def on_modified(self, event: FileSystemEvent) -> None:
|
|
66
|
+
"""Handle file modification events."""
|
|
67
|
+
if hasattr(event, "is_directory") and event.is_directory:
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
self._queue_change(event, "modified")
|
|
71
|
+
|
|
72
|
+
@beartype
|
|
73
|
+
@require(lambda self, event: event is not None, "Event must not be None")
|
|
74
|
+
def on_created(self, event: FileSystemEvent) -> None:
|
|
75
|
+
"""Handle file creation events."""
|
|
76
|
+
if hasattr(event, "is_directory") and event.is_directory:
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
self._queue_change(event, "created")
|
|
80
|
+
|
|
81
|
+
@beartype
|
|
82
|
+
@require(lambda self, event: event is not None, "Event must not be None")
|
|
83
|
+
def on_deleted(self, event: FileSystemEvent) -> None:
|
|
84
|
+
"""Handle file deletion events."""
|
|
85
|
+
if hasattr(event, "is_directory") and event.is_directory:
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
self._queue_change(event, "deleted")
|
|
89
|
+
|
|
90
|
+
@beartype
|
|
91
|
+
@require(
|
|
92
|
+
lambda self, event, event_type: event is not None,
|
|
93
|
+
"Event must not be None",
|
|
94
|
+
)
|
|
95
|
+
@require(
|
|
96
|
+
lambda self, event, event_type: event_type in ("created", "modified", "deleted"),
|
|
97
|
+
"Event type must be created, modified, or deleted",
|
|
98
|
+
)
|
|
99
|
+
@ensure(lambda result: result is None, "Must return None")
|
|
100
|
+
def _queue_change(self, event: FileSystemEvent, event_type: str) -> None:
|
|
101
|
+
"""Queue a file change event with debouncing."""
|
|
102
|
+
if not hasattr(event, "src_path"):
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
file_path = Path(str(event.src_path))
|
|
106
|
+
|
|
107
|
+
# Skip if not in repository
|
|
108
|
+
try:
|
|
109
|
+
file_path.resolve().relative_to(self.repo_path)
|
|
110
|
+
except ValueError:
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
# Debounce rapid changes to same file
|
|
114
|
+
file_key = str(file_path)
|
|
115
|
+
current_time = time.time()
|
|
116
|
+
last_time = self.last_event_time.get(file_key, 0)
|
|
117
|
+
|
|
118
|
+
if current_time - last_time < self.debounce_interval:
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
self.last_event_time[file_key] = current_time
|
|
122
|
+
|
|
123
|
+
# Determine change type based on file path
|
|
124
|
+
change_type = self._detect_change_type(file_path)
|
|
125
|
+
|
|
126
|
+
# Queue change
|
|
127
|
+
change = FileChange(
|
|
128
|
+
file_path=file_path,
|
|
129
|
+
change_type=change_type,
|
|
130
|
+
event_type=event_type,
|
|
131
|
+
timestamp=current_time,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
self.change_queue.append(change)
|
|
135
|
+
|
|
136
|
+
@beartype
|
|
137
|
+
@require(lambda self, file_path: isinstance(file_path, Path), "File path must be Path")
|
|
138
|
+
@ensure(lambda result: result in ("spec_kit", "specfact", "code"), "Change type must be valid")
|
|
139
|
+
def _detect_change_type(self, file_path: Path) -> str:
|
|
140
|
+
"""
|
|
141
|
+
Detect change type based on file path.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
file_path: Path to changed file
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Change type: "spec_kit", "specfact", or "code"
|
|
148
|
+
"""
|
|
149
|
+
path_str = str(file_path)
|
|
150
|
+
|
|
151
|
+
# Spec-Kit artifacts
|
|
152
|
+
if ".specify" in path_str or "/specs/" in path_str:
|
|
153
|
+
return "spec_kit"
|
|
154
|
+
|
|
155
|
+
# SpecFact artifacts
|
|
156
|
+
if ".specfact" in path_str:
|
|
157
|
+
return "specfact"
|
|
158
|
+
|
|
159
|
+
# Code changes (default)
|
|
160
|
+
return "code"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class SyncWatcher:
|
|
164
|
+
"""Watch mode for continuous sync operations."""
|
|
165
|
+
|
|
166
|
+
@beartype
|
|
167
|
+
@require(lambda repo_path: repo_path.exists(), "Repository path must exist")
|
|
168
|
+
@require(lambda repo_path: repo_path.is_dir(), "Repository path must be a directory")
|
|
169
|
+
@require(lambda interval: isinstance(interval, (int, float)) and interval >= 1, "Interval must be >= 1")
|
|
170
|
+
@require(
|
|
171
|
+
lambda sync_callback: callable(sync_callback),
|
|
172
|
+
"Sync callback must be callable",
|
|
173
|
+
)
|
|
174
|
+
@ensure(lambda result: result is None, "Must return None")
|
|
175
|
+
def __init__(
|
|
176
|
+
self,
|
|
177
|
+
repo_path: Path,
|
|
178
|
+
sync_callback: Callable[[list[FileChange]], None],
|
|
179
|
+
interval: int = 5,
|
|
180
|
+
) -> None:
|
|
181
|
+
"""
|
|
182
|
+
Initialize sync watcher.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
repo_path: Path to repository root
|
|
186
|
+
sync_callback: Callback function to handle sync operations
|
|
187
|
+
interval: Watch interval in seconds (default: 5)
|
|
188
|
+
"""
|
|
189
|
+
self.repo_path = Path(repo_path).resolve()
|
|
190
|
+
self.sync_callback = sync_callback
|
|
191
|
+
self.interval = interval
|
|
192
|
+
self.observer: Observer | None = None # type: ignore[assignment]
|
|
193
|
+
self.change_queue: deque[FileChange] = deque()
|
|
194
|
+
self.running = False
|
|
195
|
+
|
|
196
|
+
@beartype
|
|
197
|
+
@ensure(lambda result: result is None, "Must return None")
|
|
198
|
+
def start(self) -> None:
|
|
199
|
+
"""Start watching for file system changes."""
|
|
200
|
+
if self.running:
|
|
201
|
+
print_warning("Watcher is already running")
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
observer = Observer()
|
|
205
|
+
handler = SyncEventHandler(self.repo_path, self.change_queue)
|
|
206
|
+
observer.schedule(handler, str(self.repo_path), recursive=True)
|
|
207
|
+
observer.start()
|
|
208
|
+
|
|
209
|
+
self.observer = observer
|
|
210
|
+
self.running = True
|
|
211
|
+
print_info(f"Watching for changes in: {self.repo_path}")
|
|
212
|
+
print_info(f"Sync interval: {self.interval} seconds")
|
|
213
|
+
print_info("Press Ctrl+C to stop")
|
|
214
|
+
|
|
215
|
+
@beartype
|
|
216
|
+
@ensure(lambda result: result is None, "Must return None")
|
|
217
|
+
def stop(self) -> None:
|
|
218
|
+
"""Stop watching for file system changes."""
|
|
219
|
+
if not self.running:
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
self.running = False
|
|
223
|
+
|
|
224
|
+
if self.observer is not None:
|
|
225
|
+
self.observer.stop()
|
|
226
|
+
self.observer.join(timeout=5)
|
|
227
|
+
self.observer = None
|
|
228
|
+
|
|
229
|
+
print_info("Watch mode stopped")
|
|
230
|
+
|
|
231
|
+
@beartype
|
|
232
|
+
@ensure(lambda result: result is None, "Must return None")
|
|
233
|
+
def watch(self) -> None:
|
|
234
|
+
"""
|
|
235
|
+
Continuously watch and sync changes.
|
|
236
|
+
|
|
237
|
+
This method blocks until interrupted (Ctrl+C).
|
|
238
|
+
"""
|
|
239
|
+
self.start()
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
while self.running:
|
|
243
|
+
time.sleep(self.interval)
|
|
244
|
+
self._process_pending_changes()
|
|
245
|
+
except KeyboardInterrupt:
|
|
246
|
+
print_info("\nStopping watch mode...")
|
|
247
|
+
finally:
|
|
248
|
+
self.stop()
|
|
249
|
+
|
|
250
|
+
@beartype
|
|
251
|
+
@require(lambda self: isinstance(self.running, bool), "Watcher running state must be bool")
|
|
252
|
+
@ensure(lambda result: result is None, "Must return None")
|
|
253
|
+
def _process_pending_changes(self) -> None:
|
|
254
|
+
"""Process pending file changes and trigger sync."""
|
|
255
|
+
if not self.change_queue:
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
# Collect all pending changes
|
|
259
|
+
changes: list[FileChange] = []
|
|
260
|
+
while self.change_queue:
|
|
261
|
+
changes.append(self.change_queue.popleft())
|
|
262
|
+
|
|
263
|
+
if changes:
|
|
264
|
+
print_info(f"Detected {len(changes)} file change(s), triggering sync...")
|
|
265
|
+
try:
|
|
266
|
+
self.sync_callback(changes)
|
|
267
|
+
except Exception as e:
|
|
268
|
+
print_warning(f"Sync callback failed: {e}")
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Privacy-first telemetry utilities for SpecFact CLI.
|
|
3
|
+
|
|
4
|
+
Telemetry is disabled by default and only activates after the user
|
|
5
|
+
explicitly opts in via environment variables or the ~/.specfact/telemetry.opt-in
|
|
6
|
+
flag file. When enabled, the manager emits anonymized OpenTelemetry spans
|
|
7
|
+
and appends sanitized JSON lines to a local log file so users can inspect,
|
|
8
|
+
rotate, or delete their own data.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import time
|
|
17
|
+
from collections.abc import MutableMapping
|
|
18
|
+
from contextlib import contextmanager
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
from uuid import uuid4
|
|
23
|
+
|
|
24
|
+
from beartype import beartype
|
|
25
|
+
from beartype.typing import Callable, Iterator, Mapping
|
|
26
|
+
|
|
27
|
+
from specfact_cli import __version__
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
from opentelemetry import trace
|
|
32
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
|
33
|
+
from opentelemetry.sdk.resources import Resource
|
|
34
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
35
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter, SimpleSpanProcessor
|
|
36
|
+
except ImportError: # pragma: no cover - optional dependency
|
|
37
|
+
trace = None # type: ignore[assignment]
|
|
38
|
+
TracerProvider = None # type: ignore[assignment]
|
|
39
|
+
BatchSpanProcessor = None # type: ignore[assignment]
|
|
40
|
+
SimpleSpanProcessor = None # type: ignore[assignment]
|
|
41
|
+
ConsoleSpanExporter = None # type: ignore[assignment]
|
|
42
|
+
OTLPSpanExporter = None # type: ignore[assignment]
|
|
43
|
+
Resource = None # type: ignore[assignment]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
LOGGER = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
OPT_IN_FILE = Path.home() / ".specfact" / "telemetry.opt-in"
|
|
49
|
+
TELEMETRY_CONFIG_FILE = Path.home() / ".specfact" / "telemetry.yaml"
|
|
50
|
+
DEFAULT_LOCAL_LOG = Path.home() / ".specfact" / "telemetry.log"
|
|
51
|
+
|
|
52
|
+
ALLOWED_FIELDS = {
|
|
53
|
+
"command",
|
|
54
|
+
"mode",
|
|
55
|
+
"execution_mode",
|
|
56
|
+
"shadow_mode",
|
|
57
|
+
"files_analyzed",
|
|
58
|
+
"features_detected",
|
|
59
|
+
"stories_detected",
|
|
60
|
+
"violations_detected",
|
|
61
|
+
"checks_total",
|
|
62
|
+
"checks_failed",
|
|
63
|
+
"duration_ms",
|
|
64
|
+
"success",
|
|
65
|
+
"error",
|
|
66
|
+
"telemetry_version",
|
|
67
|
+
"session_id",
|
|
68
|
+
"opt_in_source",
|
|
69
|
+
"cli_version",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _coerce_bool(value: str | None) -> bool:
|
|
74
|
+
"""Convert truthy string representations to boolean."""
|
|
75
|
+
if value is None:
|
|
76
|
+
return False
|
|
77
|
+
return value.strip().lower() in {"1", "true", "yes", "y", "on"}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _read_opt_in_file() -> bool:
|
|
81
|
+
"""Read opt-in flag from ~/.specfact/telemetry.opt-in if it exists."""
|
|
82
|
+
try:
|
|
83
|
+
content = OPT_IN_FILE.read_text(encoding="utf-8").strip()
|
|
84
|
+
except FileNotFoundError:
|
|
85
|
+
return False
|
|
86
|
+
except OSError:
|
|
87
|
+
return False
|
|
88
|
+
return _coerce_bool(content)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _read_config_file() -> dict[str, Any]:
|
|
92
|
+
"""Read telemetry configuration from ~/.specfact/telemetry.yaml if it exists."""
|
|
93
|
+
if not TELEMETRY_CONFIG_FILE.exists():
|
|
94
|
+
return {}
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
from specfact_cli.utils.yaml_utils import load_yaml
|
|
98
|
+
|
|
99
|
+
config = load_yaml(TELEMETRY_CONFIG_FILE)
|
|
100
|
+
if not isinstance(config, dict):
|
|
101
|
+
LOGGER.warning("Invalid telemetry config file format: expected dict, got %s", type(config))
|
|
102
|
+
return {}
|
|
103
|
+
return config
|
|
104
|
+
except FileNotFoundError:
|
|
105
|
+
return {}
|
|
106
|
+
except Exception as e:
|
|
107
|
+
LOGGER.warning("Failed to read telemetry config file: %s", e)
|
|
108
|
+
return {}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _parse_headers(raw: str | None) -> dict[str, str]:
|
|
112
|
+
"""Parse comma-separated header string into a dictionary."""
|
|
113
|
+
if not raw:
|
|
114
|
+
return {}
|
|
115
|
+
headers: dict[str, str] = {}
|
|
116
|
+
for pair in raw.split(","):
|
|
117
|
+
if ":" not in pair:
|
|
118
|
+
continue
|
|
119
|
+
key, value = pair.split(":", 1)
|
|
120
|
+
key_clean = key.strip()
|
|
121
|
+
value_clean = value.strip()
|
|
122
|
+
if key_clean and value_clean:
|
|
123
|
+
headers[key_clean] = value_clean
|
|
124
|
+
return headers
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class TelemetrySettings:
|
|
129
|
+
"""User-configurable telemetry settings."""
|
|
130
|
+
|
|
131
|
+
enabled: bool
|
|
132
|
+
endpoint: str | None = None
|
|
133
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
134
|
+
local_path: Path = DEFAULT_LOCAL_LOG
|
|
135
|
+
debug: bool = False
|
|
136
|
+
opt_in_source: str = "disabled"
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
@beartype
|
|
140
|
+
def from_env(cls) -> TelemetrySettings:
|
|
141
|
+
"""
|
|
142
|
+
Build telemetry settings from environment variables, config file, and opt-in file.
|
|
143
|
+
|
|
144
|
+
Precedence (highest to lowest):
|
|
145
|
+
1. Environment variables (override everything)
|
|
146
|
+
2. Config file (~/.specfact/telemetry.yaml)
|
|
147
|
+
3. Simple opt-in file (~/.specfact/telemetry.opt-in) - for backward compatibility
|
|
148
|
+
4. Defaults (disabled)
|
|
149
|
+
"""
|
|
150
|
+
# Disable in test environments (GitHub pattern)
|
|
151
|
+
if os.getenv("TEST_MODE") == "true" or os.getenv("PYTEST_CURRENT_TEST"):
|
|
152
|
+
return cls(
|
|
153
|
+
enabled=False,
|
|
154
|
+
endpoint=None,
|
|
155
|
+
headers={},
|
|
156
|
+
local_path=DEFAULT_LOCAL_LOG,
|
|
157
|
+
debug=False,
|
|
158
|
+
opt_in_source="disabled",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Step 1: Read config file (if exists)
|
|
162
|
+
config = _read_config_file()
|
|
163
|
+
|
|
164
|
+
# Step 2: Check environment variables (override config file)
|
|
165
|
+
env_flag = os.getenv("SPECFACT_TELEMETRY_OPT_IN")
|
|
166
|
+
if env_flag is not None:
|
|
167
|
+
enabled = _coerce_bool(env_flag)
|
|
168
|
+
opt_in_source = "env" if enabled else "disabled"
|
|
169
|
+
else:
|
|
170
|
+
# Check config file for enabled flag (can be bool or string)
|
|
171
|
+
config_enabled = config.get("enabled", False)
|
|
172
|
+
if isinstance(config_enabled, bool):
|
|
173
|
+
enabled = config_enabled
|
|
174
|
+
elif isinstance(config_enabled, str):
|
|
175
|
+
enabled = _coerce_bool(config_enabled)
|
|
176
|
+
else:
|
|
177
|
+
enabled = False
|
|
178
|
+
opt_in_source = "config" if enabled else "disabled"
|
|
179
|
+
|
|
180
|
+
# Step 3: Fallback to simple opt-in file (backward compatibility)
|
|
181
|
+
if not enabled:
|
|
182
|
+
file_enabled = _read_opt_in_file()
|
|
183
|
+
if file_enabled:
|
|
184
|
+
enabled = True
|
|
185
|
+
opt_in_source = "file"
|
|
186
|
+
|
|
187
|
+
# Step 4: Get endpoint (env var > config file > None)
|
|
188
|
+
endpoint = os.getenv("SPECFACT_TELEMETRY_ENDPOINT") or config.get("endpoint")
|
|
189
|
+
|
|
190
|
+
# Step 5: Get headers (env var > config file > empty dict)
|
|
191
|
+
env_headers = _parse_headers(os.getenv("SPECFACT_TELEMETRY_HEADERS"))
|
|
192
|
+
config_headers = config.get("headers", {})
|
|
193
|
+
headers = (
|
|
194
|
+
{**config_headers, **env_headers} if isinstance(config_headers, dict) else env_headers
|
|
195
|
+
) # Env vars override config file
|
|
196
|
+
|
|
197
|
+
# Step 6: Get local path (env var > config file > default)
|
|
198
|
+
local_path_str = (
|
|
199
|
+
os.getenv("SPECFACT_TELEMETRY_LOCAL_PATH") or config.get("local_path") or str(DEFAULT_LOCAL_LOG)
|
|
200
|
+
)
|
|
201
|
+
local_path = Path(local_path_str).expanduser()
|
|
202
|
+
|
|
203
|
+
# Step 7: Get debug flag (env var > config file > False)
|
|
204
|
+
env_debug = os.getenv("SPECFACT_TELEMETRY_DEBUG")
|
|
205
|
+
debug = _coerce_bool(env_debug) if env_debug is not None else config.get("debug", False)
|
|
206
|
+
|
|
207
|
+
return cls(
|
|
208
|
+
enabled=enabled,
|
|
209
|
+
endpoint=endpoint,
|
|
210
|
+
headers=headers,
|
|
211
|
+
local_path=local_path,
|
|
212
|
+
debug=debug,
|
|
213
|
+
opt_in_source=opt_in_source if enabled else "disabled",
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class TelemetryManager:
|
|
218
|
+
"""Privacy-first telemetry helper."""
|
|
219
|
+
|
|
220
|
+
TELEMETRY_VERSION = "1.0"
|
|
221
|
+
|
|
222
|
+
@beartype
|
|
223
|
+
def __init__(self, settings: TelemetrySettings | None = None) -> None:
|
|
224
|
+
self._settings = settings or TelemetrySettings.from_env()
|
|
225
|
+
self._enabled = self._settings.enabled
|
|
226
|
+
self._session_id = uuid4().hex
|
|
227
|
+
self._tracer = None
|
|
228
|
+
self._last_event: dict[str, Any] | None = None
|
|
229
|
+
|
|
230
|
+
if not self._enabled:
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
self._prepare_storage()
|
|
234
|
+
self._initialize_tracer()
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def enabled(self) -> bool:
|
|
238
|
+
"""Return True if telemetry is active."""
|
|
239
|
+
return self._enabled
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def last_event(self) -> dict[str, Any] | None:
|
|
243
|
+
"""Expose the last emitted telemetry event (used for tests)."""
|
|
244
|
+
return self._last_event
|
|
245
|
+
|
|
246
|
+
def _prepare_storage(self) -> None:
|
|
247
|
+
"""Ensure local telemetry directory exists."""
|
|
248
|
+
try:
|
|
249
|
+
self._settings.local_path.parent.mkdir(parents=True, exist_ok=True)
|
|
250
|
+
except OSError as exc: # pragma: no cover - catastrophic filesystem issue
|
|
251
|
+
LOGGER.warning("Failed to prepare telemetry directory: %s", exc)
|
|
252
|
+
|
|
253
|
+
def _initialize_tracer(self) -> None:
|
|
254
|
+
"""Configure OpenTelemetry exporter if endpoint is provided."""
|
|
255
|
+
if not self._settings.endpoint:
|
|
256
|
+
return
|
|
257
|
+
if (
|
|
258
|
+
trace is None
|
|
259
|
+
or TracerProvider is None
|
|
260
|
+
or BatchSpanProcessor is None
|
|
261
|
+
or OTLPSpanExporter is None
|
|
262
|
+
or Resource is None
|
|
263
|
+
):
|
|
264
|
+
LOGGER.warning(
|
|
265
|
+
"Telemetry opt-in detected with endpoint set, but OpenTelemetry dependencies are missing. "
|
|
266
|
+
"Events will be stored locally only."
|
|
267
|
+
)
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
# Read config file for service name and batch settings (env vars override config)
|
|
271
|
+
config = _read_config_file()
|
|
272
|
+
|
|
273
|
+
# Allow user to customize service name (env var > config file > default)
|
|
274
|
+
service_name = os.getenv("SPECFACT_TELEMETRY_SERVICE_NAME") or config.get("service_name") or "specfact-cli"
|
|
275
|
+
# Allow user to customize service namespace (env var > config file > default)
|
|
276
|
+
service_namespace = (
|
|
277
|
+
os.getenv("SPECFACT_TELEMETRY_SERVICE_NAMESPACE") or config.get("service_namespace") or "cli"
|
|
278
|
+
)
|
|
279
|
+
# Allow user to customize deployment environment (env var > config file > default)
|
|
280
|
+
deployment_environment = (
|
|
281
|
+
os.getenv("SPECFACT_TELEMETRY_DEPLOYMENT_ENVIRONMENT")
|
|
282
|
+
or config.get("deployment_environment")
|
|
283
|
+
or "production"
|
|
284
|
+
)
|
|
285
|
+
resource = Resource.create(
|
|
286
|
+
{
|
|
287
|
+
"service.name": service_name,
|
|
288
|
+
"service.namespace": service_namespace,
|
|
289
|
+
"service.version": __version__,
|
|
290
|
+
"deployment.environment": deployment_environment,
|
|
291
|
+
"telemetry.opt_in_source": self._settings.opt_in_source,
|
|
292
|
+
}
|
|
293
|
+
)
|
|
294
|
+
provider = TracerProvider(resource=resource)
|
|
295
|
+
|
|
296
|
+
# Configure exporter (timeout is handled by BatchSpanProcessor)
|
|
297
|
+
# Export timeout (env var > config file > default)
|
|
298
|
+
export_timeout_str = os.getenv("SPECFACT_TELEMETRY_EXPORT_TIMEOUT") or str(config.get("export_timeout", "10"))
|
|
299
|
+
export_timeout = int(export_timeout_str)
|
|
300
|
+
exporter = OTLPSpanExporter(
|
|
301
|
+
endpoint=self._settings.endpoint,
|
|
302
|
+
headers=self._settings.headers or None,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
# Allow user to configure batch settings (env var > config file > default)
|
|
306
|
+
batch_size_str = os.getenv("SPECFACT_TELEMETRY_BATCH_SIZE") or str(config.get("batch_size", "512"))
|
|
307
|
+
batch_timeout_str = os.getenv("SPECFACT_TELEMETRY_BATCH_TIMEOUT") or str(config.get("batch_timeout", "5"))
|
|
308
|
+
batch_size = int(batch_size_str)
|
|
309
|
+
batch_timeout_ms = int(batch_timeout_str) * 1000 # Convert to milliseconds
|
|
310
|
+
export_timeout_ms = export_timeout * 1000 # Convert to milliseconds
|
|
311
|
+
|
|
312
|
+
provider.add_span_processor(
|
|
313
|
+
BatchSpanProcessor(
|
|
314
|
+
exporter,
|
|
315
|
+
max_queue_size=batch_size,
|
|
316
|
+
export_timeout_millis=export_timeout_ms,
|
|
317
|
+
schedule_delay_millis=batch_timeout_ms,
|
|
318
|
+
)
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
if self._settings.debug and ConsoleSpanExporter and SimpleSpanProcessor:
|
|
322
|
+
provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))
|
|
323
|
+
|
|
324
|
+
trace.set_tracer_provider(provider)
|
|
325
|
+
self._tracer = trace.get_tracer("specfact_cli.telemetry")
|
|
326
|
+
|
|
327
|
+
def _sanitize(self, raw: Mapping[str, Any] | None) -> dict[str, Any]:
|
|
328
|
+
"""Whitelist metadata fields to avoid leaking sensitive information."""
|
|
329
|
+
sanitized: dict[str, Any] = {}
|
|
330
|
+
if not raw:
|
|
331
|
+
return sanitized
|
|
332
|
+
|
|
333
|
+
for key, value in raw.items():
|
|
334
|
+
if key not in ALLOWED_FIELDS:
|
|
335
|
+
continue
|
|
336
|
+
normalized = self._normalize_value(value)
|
|
337
|
+
if normalized is not None:
|
|
338
|
+
sanitized[key] = normalized
|
|
339
|
+
return sanitized
|
|
340
|
+
|
|
341
|
+
def _normalize_value(self, value: Any) -> bool | int | float | str | None:
|
|
342
|
+
"""Normalize values to primitive types suitable for telemetry."""
|
|
343
|
+
if isinstance(value, bool):
|
|
344
|
+
return value
|
|
345
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
346
|
+
return value
|
|
347
|
+
if isinstance(value, float):
|
|
348
|
+
return round(value, 4)
|
|
349
|
+
if isinstance(value, str):
|
|
350
|
+
trimmed = value.strip()
|
|
351
|
+
if not trimmed:
|
|
352
|
+
return None
|
|
353
|
+
return trimmed[:128]
|
|
354
|
+
if value is None:
|
|
355
|
+
return None
|
|
356
|
+
if isinstance(value, (list, tuple)):
|
|
357
|
+
return len(value)
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
def _write_local_event(self, event: Mapping[str, Any]) -> None:
|
|
361
|
+
"""Persist event to local JSONL file."""
|
|
362
|
+
try:
|
|
363
|
+
with self._settings.local_path.open("a", encoding="utf-8") as handle:
|
|
364
|
+
handle.write(json.dumps(event, separators=(",", ":")))
|
|
365
|
+
handle.write("\n")
|
|
366
|
+
except OSError as exc: # pragma: no cover - filesystem failures
|
|
367
|
+
LOGGER.warning("Failed to write telemetry event locally: %s", exc)
|
|
368
|
+
|
|
369
|
+
def _emit_event(self, event: MutableMapping[str, Any]) -> None:
|
|
370
|
+
"""Emit sanitized event to local storage and optional OTLP exporter."""
|
|
371
|
+
event.setdefault("cli_version", __version__)
|
|
372
|
+
event.setdefault("opt_in_source", self._settings.opt_in_source)
|
|
373
|
+
|
|
374
|
+
self._last_event = dict(event)
|
|
375
|
+
self._write_local_event(self._last_event)
|
|
376
|
+
|
|
377
|
+
if self._tracer is None:
|
|
378
|
+
return
|
|
379
|
+
|
|
380
|
+
# Emit to OTLP exporter with error handling
|
|
381
|
+
span_name = f"specfact.{event.get('command', 'unknown')}"
|
|
382
|
+
try:
|
|
383
|
+
with self._tracer.start_as_current_span(span_name) as span: # pragma: no cover - exercised indirectly
|
|
384
|
+
for key, value in self._last_event.items():
|
|
385
|
+
span.set_attribute(f"specfact.{key}", value)
|
|
386
|
+
except Exception as exc: # pragma: no cover - collector failures
|
|
387
|
+
# Log but don't fail - local storage already succeeded
|
|
388
|
+
LOGGER.warning("Failed to export telemetry to OTLP collector: %s. Event stored locally only.", exc)
|
|
389
|
+
|
|
390
|
+
@contextmanager
|
|
391
|
+
@beartype
|
|
392
|
+
def track_command(
|
|
393
|
+
self,
|
|
394
|
+
command: str,
|
|
395
|
+
initial_metadata: Mapping[str, Any] | None = None,
|
|
396
|
+
) -> Iterator[Callable[[Mapping[str, Any] | None], None]]:
|
|
397
|
+
"""
|
|
398
|
+
Context manager to record anonymized telemetry for a CLI command.
|
|
399
|
+
|
|
400
|
+
Usage:
|
|
401
|
+
with telemetry.track_command("import.from_code", {"mode": "cicd"}) as record:
|
|
402
|
+
...
|
|
403
|
+
record({"features_detected": len(features)})
|
|
404
|
+
"""
|
|
405
|
+
|
|
406
|
+
if not self._enabled:
|
|
407
|
+
yield lambda _: None
|
|
408
|
+
return
|
|
409
|
+
|
|
410
|
+
metadata: dict[str, Any] = self._sanitize(initial_metadata)
|
|
411
|
+
start_time = time.perf_counter()
|
|
412
|
+
success = False
|
|
413
|
+
error_name: str | None = None
|
|
414
|
+
|
|
415
|
+
def record(extra: Mapping[str, Any] | None) -> None:
|
|
416
|
+
if extra:
|
|
417
|
+
metadata.update(self._sanitize(extra))
|
|
418
|
+
|
|
419
|
+
try:
|
|
420
|
+
yield record
|
|
421
|
+
success = True
|
|
422
|
+
except Exception as exc:
|
|
423
|
+
error_name = exc.__class__.__name__
|
|
424
|
+
metadata["error"] = error_name
|
|
425
|
+
raise
|
|
426
|
+
finally:
|
|
427
|
+
metadata.setdefault("session_id", self._session_id)
|
|
428
|
+
metadata["success"] = success
|
|
429
|
+
if error_name:
|
|
430
|
+
metadata["error"] = error_name
|
|
431
|
+
metadata["duration_ms"] = round((time.perf_counter() - start_time) * 1000, 2)
|
|
432
|
+
metadata["command"] = command
|
|
433
|
+
metadata["telemetry_version"] = self.TELEMETRY_VERSION
|
|
434
|
+
self._emit_event(metadata)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# Shared singleton used throughout the CLI.
|
|
438
|
+
telemetry = TelemetryManager()
|
|
439
|
+
|
|
440
|
+
__all__ = ["TelemetryManager", "TelemetrySettings", "telemetry"]
|