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.
Files changed (66) hide show
  1. specfact_cli/__init__.py +1 -1
  2. specfact_cli/agents/analyze_agent.py +2 -3
  3. specfact_cli/analyzers/__init__.py +2 -1
  4. specfact_cli/analyzers/ambiguity_scanner.py +601 -0
  5. specfact_cli/analyzers/code_analyzer.py +462 -30
  6. specfact_cli/analyzers/constitution_evidence_extractor.py +491 -0
  7. specfact_cli/analyzers/contract_extractor.py +419 -0
  8. specfact_cli/analyzers/control_flow_analyzer.py +281 -0
  9. specfact_cli/analyzers/requirement_extractor.py +337 -0
  10. specfact_cli/analyzers/test_pattern_extractor.py +330 -0
  11. specfact_cli/cli.py +151 -206
  12. specfact_cli/commands/constitution.py +281 -0
  13. specfact_cli/commands/enforce.py +42 -34
  14. specfact_cli/commands/import_cmd.py +481 -152
  15. specfact_cli/commands/init.py +224 -55
  16. specfact_cli/commands/plan.py +2133 -547
  17. specfact_cli/commands/repro.py +100 -78
  18. specfact_cli/commands/sync.py +701 -186
  19. specfact_cli/enrichers/constitution_enricher.py +765 -0
  20. specfact_cli/enrichers/plan_enricher.py +294 -0
  21. specfact_cli/importers/speckit_converter.py +364 -48
  22. specfact_cli/importers/speckit_scanner.py +65 -0
  23. specfact_cli/models/plan.py +42 -0
  24. specfact_cli/resources/mappings/node-async.yaml +49 -0
  25. specfact_cli/resources/mappings/python-async.yaml +47 -0
  26. specfact_cli/resources/mappings/speckit-default.yaml +82 -0
  27. specfact_cli/resources/prompts/specfact-enforce.md +185 -0
  28. specfact_cli/resources/prompts/specfact-import-from-code.md +626 -0
  29. specfact_cli/resources/prompts/specfact-plan-add-feature.md +188 -0
  30. specfact_cli/resources/prompts/specfact-plan-add-story.md +212 -0
  31. specfact_cli/resources/prompts/specfact-plan-compare.md +571 -0
  32. specfact_cli/resources/prompts/specfact-plan-init.md +531 -0
  33. specfact_cli/resources/prompts/specfact-plan-promote.md +352 -0
  34. specfact_cli/resources/prompts/specfact-plan-review.md +1276 -0
  35. specfact_cli/resources/prompts/specfact-plan-select.md +401 -0
  36. specfact_cli/resources/prompts/specfact-plan-update-feature.md +242 -0
  37. specfact_cli/resources/prompts/specfact-plan-update-idea.md +211 -0
  38. specfact_cli/resources/prompts/specfact-repro.md +268 -0
  39. specfact_cli/resources/prompts/specfact-sync.md +497 -0
  40. specfact_cli/resources/schemas/deviation.schema.json +61 -0
  41. specfact_cli/resources/schemas/plan.schema.json +204 -0
  42. specfact_cli/resources/schemas/protocol.schema.json +53 -0
  43. specfact_cli/resources/templates/github-action.yml.j2 +140 -0
  44. specfact_cli/resources/templates/plan.bundle.yaml.j2 +141 -0
  45. specfact_cli/resources/templates/pr-template.md.j2 +58 -0
  46. specfact_cli/resources/templates/protocol.yaml.j2 +24 -0
  47. specfact_cli/resources/templates/telemetry.yaml.example +35 -0
  48. specfact_cli/sync/__init__.py +10 -1
  49. specfact_cli/sync/watcher.py +268 -0
  50. specfact_cli/telemetry.py +440 -0
  51. specfact_cli/utils/acceptance_criteria.py +127 -0
  52. specfact_cli/utils/enrichment_parser.py +445 -0
  53. specfact_cli/utils/feature_keys.py +12 -3
  54. specfact_cli/utils/ide_setup.py +170 -0
  55. specfact_cli/utils/structure.py +179 -2
  56. specfact_cli/utils/yaml_utils.py +33 -0
  57. specfact_cli/validators/repro_checker.py +22 -1
  58. specfact_cli/validators/schema.py +15 -4
  59. specfact_cli-0.6.8.dist-info/METADATA +456 -0
  60. specfact_cli-0.6.8.dist-info/RECORD +99 -0
  61. {specfact_cli-0.4.2.dist-info → specfact_cli-0.6.8.dist-info}/entry_points.txt +1 -0
  62. specfact_cli-0.6.8.dist-info/licenses/LICENSE.md +202 -0
  63. specfact_cli-0.4.2.dist-info/METADATA +0 -370
  64. specfact_cli-0.4.2.dist-info/RECORD +0 -62
  65. specfact_cli-0.4.2.dist-info/licenses/LICENSE.md +0 -61
  66. {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"]