sourcecode 0.26.0__py3-none-any.whl → 0.27.0__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.
sourcecode/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """sourcecode — Genera mapas de contexto estructurado para agentes IA."""
2
2
 
3
- __version__ = "0.26.0"
3
+ __version__ = "0.27.0"
sourcecode/cli.py CHANGED
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import time
4
5
  from pathlib import Path
5
6
  from typing import Any, Optional, cast
6
7
 
@@ -10,10 +11,53 @@ from sourcecode import __version__
10
11
 
11
12
  app = typer.Typer(
12
13
  name="sourcecode",
13
- help="Genera un mapa de contexto estructurado del proyecto para agentes IA.",
14
+ help="Deterministic codebase context for AI coding agents.",
14
15
  add_completion=False,
15
16
  )
16
17
 
18
+ telemetry_app = typer.Typer(help="Manage anonymous telemetry (opt-in).")
19
+ app.add_typer(telemetry_app, name="telemetry")
20
+
21
+
22
+ def _maybe_ask_consent() -> None:
23
+ """Show first-run consent prompt once, on interactive TTYs only."""
24
+ try:
25
+ from sourcecode.telemetry.config import has_been_asked, mark_asked, set_enabled
26
+ from sourcecode.telemetry.consent import ask_for_consent
27
+ if not has_been_asked():
28
+ enabled = ask_for_consent()
29
+ set_enabled(enabled)
30
+ if enabled:
31
+ typer.echo("Telemetry enabled. Thank you. Disable: sourcecode telemetry disable", err=True)
32
+ else:
33
+ typer.echo("Telemetry disabled. Enable anytime: sourcecode telemetry enable", err=True)
34
+ except Exception:
35
+ pass
36
+
37
+
38
+ def _active_flags(
39
+ dependencies: bool, graph_modules: bool, docs: bool, full_metrics: bool,
40
+ semantics: bool, architecture: bool, git_context: bool, env_map: bool,
41
+ code_notes: bool, agent: bool, compact: bool, tree: bool, no_redact: bool,
42
+ fmt: str,
43
+ ) -> list[str]:
44
+ flags: list[str] = []
45
+ if agent: flags.append("--agent")
46
+ if compact: flags.append("--compact")
47
+ if dependencies: flags.append("--dependencies")
48
+ if graph_modules: flags.append("--graph-modules")
49
+ if docs: flags.append("--docs")
50
+ if full_metrics: flags.append("--full-metrics")
51
+ if semantics: flags.append("--semantics")
52
+ if architecture: flags.append("--architecture")
53
+ if git_context: flags.append("--git-context")
54
+ if env_map: flags.append("--env-map")
55
+ if code_notes: flags.append("--code-notes")
56
+ if tree: flags.append("--tree")
57
+ if no_redact: flags.append("--no-redact")
58
+ if fmt != "json": flags.append("--format")
59
+ return flags
60
+
17
61
  FORMAT_CHOICES = ["json", "yaml"]
18
62
  GRAPH_DETAIL_CHOICES = ["high", "medium", "full"]
19
63
  GRAPH_EDGE_CHOICES = {"imports", "calls", "contains", "extends"}
@@ -167,11 +211,17 @@ def main(
167
211
  help="Modo agente: output estructurado y sin ruido para consumo por IA. Incluye identidad, entrypoints, arquitectura, dependencias clave, señales operacionales y gaps. Sin arbol de ficheros ni secciones vacias.",
168
212
  ),
169
213
  ) -> None:
170
- """Genera un mapa de contexto estructurado del proyecto en formato JSON o YAML."""
171
- # When a subcommand (e.g. prepare-context) is invoked, skip the main analysis.
214
+ """Generate structured codebase context for AI coding agents."""
215
+ # First-run consent (skip for telemetry subcommand itself)
216
+ if ctx.invoked_subcommand != "telemetry":
217
+ _maybe_ask_consent()
218
+
219
+ # When a subcommand is invoked, skip the main analysis.
172
220
  if ctx.invoked_subcommand is not None:
173
221
  return
174
222
 
223
+ _t0 = time.monotonic()
224
+
175
225
  # Validar formato
176
226
  if format not in FORMAT_CHOICES:
177
227
  typer.echo(
@@ -672,7 +722,26 @@ def main(
672
722
  else:
673
723
  content = json.dumps(raw_dict, indent=2, ensure_ascii=False)
674
724
 
675
- # 5. Escribir output (CLI-04)
725
+ # 5. Telemetry (fire-and-forget, never blocks)
726
+ try:
727
+ from sourcecode import telemetry as _tel
728
+ _tel.record(
729
+ "execution_completed",
730
+ cmd="analyze",
731
+ flags=_active_flags(
732
+ dependencies, graph_modules, docs, full_metrics,
733
+ semantics, architecture, git_context, env_map,
734
+ code_notes, agent, compact, tree, no_redact, format,
735
+ ),
736
+ output_fmt=format,
737
+ file_count=len(sm.file_paths),
738
+ duration_s=time.monotonic() - _t0,
739
+ success=True,
740
+ )
741
+ except Exception:
742
+ pass
743
+
744
+ # 6. Escribir output (CLI-04)
676
745
  write_output(content, output=output)
677
746
 
678
747
 
@@ -804,3 +873,42 @@ def prepare_context_cmd(
804
873
  out["llm_prompt"] = builder.render_prompt(output)
805
874
 
806
875
  typer.echo(json.dumps(out, indent=2, ensure_ascii=False))
876
+
877
+
878
+ # ── Telemetry commands ────────────────────────────────────────────────────────
879
+
880
+ @telemetry_app.command("status")
881
+ def telemetry_status() -> None:
882
+ """Show current telemetry setting."""
883
+ from sourcecode.telemetry.config import config_file_path, has_been_asked, is_enabled
884
+ enabled = is_enabled()
885
+ asked = has_been_asked()
886
+ status = "enabled" if enabled else "disabled"
887
+ typer.echo(f"Telemetry: {status}")
888
+ if not asked:
889
+ typer.echo(" (consent not yet shown — will prompt on next run)")
890
+ typer.echo(f" Config: {config_file_path()}")
891
+ typer.echo(" Disable permanently: sourcecode telemetry disable")
892
+ typer.echo(" Or set env var: SOURCECODE_TELEMETRY=0")
893
+
894
+
895
+ @telemetry_app.command("enable")
896
+ def telemetry_enable() -> None:
897
+ """Opt in to anonymous telemetry."""
898
+ from sourcecode.telemetry.config import set_enabled
899
+ from sourcecode import telemetry as _tel
900
+ set_enabled(True)
901
+ typer.echo("Telemetry enabled. Thank you — this helps improve sourcecode.")
902
+ typer.echo("What is collected: version, OS, commands, flags, duration, repo size range, errors.")
903
+ typer.echo("What is never collected: source code, paths, secrets, or any output content.")
904
+ typer.echo("Disable at any time: sourcecode telemetry disable")
905
+ _tel.record("telemetry_enabled", cmd="telemetry")
906
+
907
+
908
+ @telemetry_app.command("disable")
909
+ def telemetry_disable() -> None:
910
+ """Opt out of anonymous telemetry."""
911
+ from sourcecode.telemetry.config import set_enabled
912
+ set_enabled(False)
913
+ typer.echo("Telemetry disabled. No data will be collected or sent.")
914
+ typer.echo("Re-enable at any time: sourcecode telemetry enable")
@@ -0,0 +1,111 @@
1
+ """sourcecode telemetry — opt-in anonymous usage metrics.
2
+
3
+ Public API:
4
+ is_enabled() → bool
5
+ record(event, **kw) → None (fire-and-forget)
6
+ session_id() → str (ephemeral 8-char hex, new each process)
7
+
8
+ Telemetry is strictly opt-in. It is disabled by default and can be disabled
9
+ at any time via `sourcecode telemetry disable` or SOURCECODE_TELEMETRY=0.
10
+
11
+ Nothing sensitive (code, paths, secrets, output) is ever collected.
12
+ See docs/privacy.md for full details.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import platform
18
+ import sys
19
+ import uuid
20
+ from typing import Any, Optional
21
+
22
+ from sourcecode.telemetry.config import is_enabled
23
+ from sourcecode.telemetry.events import (
24
+ TelemetryEvent,
25
+ duration_bucket,
26
+ file_count_bucket,
27
+ )
28
+ from sourcecode.telemetry.filters import sanitize
29
+ from sourcecode.telemetry.transport import send
30
+
31
+ # Ephemeral session identifier — new random value each process start.
32
+ # 8 hex chars, never persisted, used only to correlate events within one run.
33
+ _SESSION: str = uuid.uuid4().hex[:8]
34
+
35
+
36
+ def session_id() -> str:
37
+ return _SESSION
38
+
39
+
40
+ def _platform_os() -> str:
41
+ system = platform.system().lower()
42
+ if system == "darwin":
43
+ return "macos"
44
+ if system in ("linux", "windows"):
45
+ return system
46
+ return "other"
47
+
48
+
49
+ def _platform_arch() -> str:
50
+ machine = platform.machine().lower()
51
+ if machine in ("x86_64", "amd64", "i386", "i686"):
52
+ return "x64"
53
+ if machine in ("arm64", "aarch64", "armv8l"):
54
+ return "arm64"
55
+ return "other"
56
+
57
+
58
+ def _python_version() -> str:
59
+ return f"{sys.version_info.major}.{sys.version_info.minor}"
60
+
61
+
62
+ def _sourcecode_version() -> str:
63
+ try:
64
+ from sourcecode import __version__
65
+ return __version__
66
+ except Exception:
67
+ return "unknown"
68
+
69
+
70
+ def record(
71
+ event: str,
72
+ *,
73
+ cmd: str = "analyze",
74
+ flags: Optional[list[str]] = None,
75
+ output_fmt: str = "json",
76
+ file_count: Optional[int] = None,
77
+ duration_s: Optional[float] = None,
78
+ success: bool = True,
79
+ error_kind: Optional[str] = None,
80
+ ) -> None:
81
+ """Record a telemetry event. Fire-and-forget — never blocks or raises.
82
+
83
+ All data is privacy-filtered before transmission. If telemetry is disabled,
84
+ this function returns immediately without doing anything.
85
+ """
86
+ if not is_enabled():
87
+ return
88
+
89
+ try:
90
+ ev = TelemetryEvent(
91
+ event=event,
92
+ v=_sourcecode_version(),
93
+ py=_python_version(),
94
+ os=_platform_os(),
95
+ arch=_platform_arch(),
96
+ cmd=cmd,
97
+ flags=flags or [],
98
+ output_fmt=output_fmt,
99
+ repo_size=file_count_bucket(file_count) if file_count is not None else "unknown",
100
+ duration=duration_bucket(duration_s) if duration_s is not None else "unknown",
101
+ success=success,
102
+ error_kind=error_kind,
103
+ session=_SESSION,
104
+ )
105
+ payload = sanitize(ev)
106
+ send(payload)
107
+ except Exception:
108
+ pass # telemetry must never affect the main process
109
+
110
+
111
+ __all__ = ["is_enabled", "record", "session_id"]
@@ -0,0 +1,67 @@
1
+ """Persistent telemetry configuration.
2
+
3
+ Config file: ~/.config/sourcecode/config.json
4
+ Env override: SOURCECODE_TELEMETRY=0 (disable) or =1 (enable)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ _ENV_VAR = "SOURCECODE_TELEMETRY"
15
+ _CONFIG_FILE = Path.home() / ".config" / "sourcecode" / "config.json"
16
+
17
+
18
+ def _load() -> dict[str, Any]:
19
+ try:
20
+ return json.loads(_CONFIG_FILE.read_text(encoding="utf-8")) # type: ignore[no-any-return]
21
+ except Exception:
22
+ return {}
23
+
24
+
25
+ def _save(data: dict[str, Any]) -> None:
26
+ try:
27
+ _CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
28
+ _CONFIG_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
29
+ except Exception:
30
+ pass # config write failure is non-fatal
31
+
32
+
33
+ def is_enabled() -> bool:
34
+ """True only when telemetry is explicitly opted in.
35
+
36
+ Env var takes absolute precedence over config file.
37
+ Default is always disabled — telemetry is strictly opt-in.
38
+ """
39
+ env = os.environ.get(_ENV_VAR, "").strip()
40
+ if env == "0":
41
+ return False
42
+ if env == "1":
43
+ return True
44
+ return bool(_load().get("telemetry", {}).get("enabled", False))
45
+
46
+
47
+ def has_been_asked() -> bool:
48
+ """True if the consent prompt has already been shown."""
49
+ return bool(_load().get("telemetry", {}).get("asked", False))
50
+
51
+
52
+ def set_enabled(value: bool) -> None:
53
+ data = _load()
54
+ data.setdefault("telemetry", {})["enabled"] = value
55
+ data["telemetry"]["asked"] = True
56
+ _save(data)
57
+
58
+
59
+ def mark_asked() -> None:
60
+ """Record that the consent prompt was shown (regardless of answer)."""
61
+ data = _load()
62
+ data.setdefault("telemetry", {})["asked"] = True
63
+ _save(data)
64
+
65
+
66
+ def config_file_path() -> Path:
67
+ return _CONFIG_FILE
@@ -0,0 +1,63 @@
1
+ """First-run consent prompt.
2
+
3
+ Shown exactly once, only on interactive TTYs, only when the user hasn't been
4
+ asked before. Default answer is NO — the user must explicitly type 'y' to opt in.
5
+
6
+ Prompt is written to stderr so it doesn't pollute stdout output.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import sys
13
+
14
+ _PROMPT = """\
15
+ \033[2m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
16
+ sourcecode — optional anonymous telemetry
17
+
18
+ Help improve sourcecode by sharing anonymous usage metrics.
19
+
20
+ Collected: tool version, Python version, OS, commands used,
21
+ flags used, approximate repo size, execution duration, errors.
22
+
23
+ Never collected: source code, file paths, file names, secrets,
24
+ tokens, environment variables, or any repository content.
25
+
26
+ You can change this at any time:
27
+ sourcecode telemetry disable
28
+ export SOURCECODE_TELEMETRY=0
29
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\033[0m
30
+ """
31
+
32
+
33
+ def _is_interactive() -> bool:
34
+ """True when running in an interactive terminal (not CI, not piped)."""
35
+ if not sys.stdin.isatty() or not sys.stderr.isatty():
36
+ return False
37
+ # Common CI environment variables
38
+ ci_vars = {
39
+ "CI", "CONTINUOUS_INTEGRATION", "GITHUB_ACTIONS", "CIRCLECI",
40
+ "TRAVIS", "JENKINS_URL", "BUILDKITE", "GITLAB_CI", "TF_BUILD",
41
+ "TEAMCITY_VERSION", "DRONE", "SEMAPHORE",
42
+ }
43
+ return not any(os.environ.get(v) for v in ci_vars)
44
+
45
+
46
+ def ask_for_consent() -> bool:
47
+ """Show the consent prompt and return the user's choice.
48
+
49
+ Returns True if the user opted in, False otherwise.
50
+ Never raises. Default (Enter / non-y input) is False.
51
+ """
52
+ if not _is_interactive():
53
+ return False
54
+
55
+ try:
56
+ sys.stderr.write(_PROMPT)
57
+ sys.stderr.write(" Enable anonymous telemetry? [y/N]: ")
58
+ sys.stderr.flush()
59
+ answer = sys.stdin.readline().strip().lower()
60
+ sys.stderr.write("\n")
61
+ return answer == "y"
62
+ except Exception:
63
+ return False
@@ -0,0 +1,78 @@
1
+ """Telemetry event schema.
2
+
3
+ All fields are either categorical (string enum), numeric ranges (string bucket),
4
+ or bounded scalars. No free-form text, no paths, no identifiers.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from datetime import datetime, timezone
11
+ from typing import Optional
12
+
13
+
14
+ def _now_utc() -> str:
15
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
16
+
17
+
18
+ def file_count_bucket(n: int) -> str:
19
+ """Convert file count to anonymous range bucket."""
20
+ if n < 50:
21
+ return "tiny"
22
+ if n < 500:
23
+ return "small"
24
+ if n < 2000:
25
+ return "medium"
26
+ if n < 10000:
27
+ return "large"
28
+ return "huge"
29
+
30
+
31
+ def duration_bucket(seconds: float) -> str:
32
+ """Approximate duration without exposing exact timing."""
33
+ if seconds < 1.0:
34
+ return "<1s"
35
+ if seconds < 5.0:
36
+ return "<5s"
37
+ if seconds < 15.0:
38
+ return "<15s"
39
+ if seconds < 60.0:
40
+ return "<60s"
41
+ return "60s+"
42
+
43
+
44
+ @dataclass
45
+ class TelemetryEvent:
46
+ """Minimal, privacy-safe telemetry event.
47
+
48
+ Fields:
49
+ event — event name (command_executed / execution_completed / execution_failed)
50
+ ts — ISO 8601 UTC timestamp
51
+ v — sourcecode version
52
+ py — Python major.minor (e.g. "3.11")
53
+ os — OS family: linux | macos | windows | other
54
+ arch — CPU architecture: x64 | arm64 | other
55
+ cmd — command: analyze | prepare-context | telemetry
56
+ flags — flag names only (no values)
57
+ output_fmt — json | yaml
58
+ repo_size — tiny | small | medium | large | huge
59
+ duration — <1s | <5s | <15s | <60s | 60s+
60
+ success — True/False
61
+ error_kind — exception class name only (no message, no traceback)
62
+ session — 8-char random hex, ephemeral, NOT persisted
63
+ """
64
+
65
+ event: str
66
+ ts: str = field(default_factory=_now_utc)
67
+ v: str = ""
68
+ py: str = ""
69
+ os: str = ""
70
+ arch: str = ""
71
+ cmd: str = ""
72
+ flags: list[str] = field(default_factory=list)
73
+ output_fmt: str = "json"
74
+ repo_size: str = "unknown"
75
+ duration: str = "unknown"
76
+ success: bool = True
77
+ error_kind: Optional[str] = None
78
+ session: str = ""
@@ -0,0 +1,140 @@
1
+ """Privacy filter — last line of defense before any event leaves the process.
2
+
3
+ Every event is passed through this filter before transmission.
4
+ Any field that could carry sensitive data is stripped or replaced.
5
+
6
+ Rules enforced:
7
+ - No string longer than 64 characters
8
+ - No strings containing path separators (/ or \\)
9
+ - No strings containing whitespace (could be file contents)
10
+ - flags list: only known safe flag names (allowlist)
11
+ - error_kind: class name only, no message text
12
+ - All unknown/unexpected fields are dropped
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from dataclasses import asdict
19
+ from typing import Any
20
+
21
+ from sourcecode.telemetry.events import TelemetryEvent
22
+
23
+ # Allowlist of known-safe flag names. Only these can appear in the flags field.
24
+ _SAFE_FLAGS: frozenset[str] = frozenset({
25
+ "--agent",
26
+ "--compact",
27
+ "--dependencies",
28
+ "--graph-modules",
29
+ "--graph-detail",
30
+ "--graph-edges",
31
+ "--max-nodes",
32
+ "--docs",
33
+ "--docs-depth",
34
+ "--full-metrics",
35
+ "--semantics",
36
+ "--architecture",
37
+ "--git-context",
38
+ "--git-depth",
39
+ "--git-days",
40
+ "--env-map",
41
+ "--code-notes",
42
+ "--format",
43
+ "--output",
44
+ "--depth",
45
+ "--no-tree",
46
+ "--tree",
47
+ "--no-redact",
48
+ "--compact",
49
+ "--version",
50
+ "--help",
51
+ "--llm-prompt",
52
+ "--since",
53
+ "--task-help",
54
+ "--dry-run",
55
+ })
56
+
57
+ _SAFE_OS: frozenset[str] = frozenset({"linux", "macos", "windows", "other"})
58
+ _SAFE_ARCH: frozenset[str] = frozenset({"x64", "arm64", "other"})
59
+ _SAFE_CMD: frozenset[str] = frozenset({"analyze", "prepare-context", "telemetry", "unknown"})
60
+ _SAFE_EVENTS: frozenset[str] = frozenset({
61
+ "command_executed",
62
+ "execution_completed",
63
+ "execution_failed",
64
+ "telemetry_enabled",
65
+ "telemetry_disabled",
66
+ })
67
+ _SAFE_SIZES: frozenset[str] = frozenset({"tiny", "small", "medium", "large", "huge", "unknown"})
68
+ _SAFE_DURATIONS: frozenset[str] = frozenset({"<1s", "<5s", "<15s", "<60s", "60s+", "unknown"})
69
+ _SAFE_FMTS: frozenset[str] = frozenset({"json", "yaml"})
70
+
71
+ # Pattern that looks like a path segment
72
+ _PATH_PATTERN = re.compile(r"[/\\]|^\.|\.py$|\.js$|\.ts$|\.go$")
73
+ # Any string with spaces (could be a sentence / file content)
74
+ _SPACE_PATTERN = re.compile(r"\s")
75
+
76
+
77
+ def _safe_str(value: str, allowed: frozenset[str], fallback: str = "other") -> str:
78
+ """Return value if it's in the allowlist, otherwise fallback."""
79
+ return value if value in allowed else fallback
80
+
81
+
82
+ def _safe_flags(flags: list[str]) -> list[str]:
83
+ """Return only flags in the explicit allowlist."""
84
+ return sorted(f for f in flags if f in _SAFE_FLAGS)
85
+
86
+
87
+ def _safe_error_kind(value: str | None) -> str | None:
88
+ """Keep only the exception class name. Drop any message text."""
89
+ if not value:
90
+ return None
91
+ # Exception class names are CamelCase identifiers, no spaces, no paths
92
+ name = value.split(":")[-1].strip() # strip message after ':'
93
+ name = name.split(".")[-1].strip() # strip module prefix
94
+ if len(name) > 64 or _SPACE_PATTERN.search(name) or _PATH_PATTERN.search(name):
95
+ return "UnknownError"
96
+ return name[:64] if name else None
97
+
98
+
99
+ def _safe_session(value: str) -> str:
100
+ """Session ID must be a short hex string only."""
101
+ if re.match(r"^[0-9a-f]{1,16}$", value):
102
+ return value
103
+ return ""
104
+
105
+
106
+ def sanitize(event: TelemetryEvent) -> dict[str, Any]:
107
+ """Apply privacy filter to event and return a safe dict for transmission.
108
+
109
+ This is the single choke point through which all data must pass.
110
+ If any field cannot be validated as safe, it is replaced with a safe default.
111
+ """
112
+ safe: dict[str, Any] = {
113
+ "event": _safe_str(event.event, _SAFE_EVENTS, "command_executed"),
114
+ "ts": event.ts[:20] if event.ts else "", # truncate to date+time, no sub-second
115
+ "v": event.v[:16] if event.v else "",
116
+ "py": event.py[:8] if event.py else "",
117
+ "os": _safe_str(event.os, _SAFE_OS, "other"),
118
+ "arch": _safe_str(event.arch, _SAFE_ARCH, "other"),
119
+ "cmd": _safe_str(event.cmd, _SAFE_CMD, "unknown"),
120
+ "flags": _safe_flags(event.flags),
121
+ "output_fmt": _safe_str(event.output_fmt, _SAFE_FMTS, "json"),
122
+ "repo_size": _safe_str(event.repo_size, _SAFE_SIZES, "unknown"),
123
+ "duration": _safe_str(event.duration, _SAFE_DURATIONS, "unknown"),
124
+ "success": bool(event.success),
125
+ }
126
+
127
+ if event.error_kind:
128
+ safe["error_kind"] = _safe_error_kind(event.error_kind)
129
+
130
+ session = _safe_session(event.session)
131
+ if session:
132
+ safe["session"] = session
133
+
134
+ # Final validation: reject any field value that looks like a path or long string
135
+ for key, val in list(safe.items()):
136
+ if isinstance(val, str):
137
+ if len(val) > 64 or _PATH_PATTERN.search(val):
138
+ safe[key] = ""
139
+
140
+ return safe
@@ -0,0 +1,52 @@
1
+ """Fire-and-forget HTTP transport for telemetry events.
2
+
3
+ Design principles:
4
+ - Never blocks the main thread (daemon thread)
5
+ - Never raises or prints errors to the user
6
+ - Short timeout (3s) — drop and move on
7
+ - No retries — a missed event is fine
8
+ - Endpoint configurable via SOURCECODE_TELEMETRY_ENDPOINT env var
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import threading
16
+ from typing import Any
17
+
18
+ _DEFAULT_ENDPOINT = "https://t.sourcecode.dev/v1/event"
19
+ _TIMEOUT_S = 3
20
+
21
+
22
+ def _endpoint() -> str:
23
+ return os.environ.get("SOURCECODE_TELEMETRY_ENDPOINT", _DEFAULT_ENDPOINT)
24
+
25
+
26
+ def _send_blocking(payload: dict[str, Any]) -> None:
27
+ """Blocking send — runs inside a daemon thread only."""
28
+ try:
29
+ import urllib.request
30
+
31
+ data = json.dumps(payload, separators=(",", ":")).encode("utf-8")
32
+ req = urllib.request.Request(
33
+ _endpoint(),
34
+ data=data,
35
+ headers={
36
+ "Content-Type": "application/json",
37
+ "User-Agent": f"sourcecode/{payload.get('v', 'unknown')}",
38
+ },
39
+ method="POST",
40
+ )
41
+ urllib.request.urlopen(req, timeout=_TIMEOUT_S)
42
+ except Exception:
43
+ pass # always silent — a missed event is not an error
44
+
45
+
46
+ def send(payload: dict[str, Any]) -> None:
47
+ """Dispatch payload to the telemetry endpoint in a background daemon thread.
48
+
49
+ Returns immediately. The main process can exit without waiting.
50
+ """
51
+ t = threading.Thread(target=_send_blocking, args=(payload,), daemon=True)
52
+ t.start()