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 +1 -1
- sourcecode/cli.py +112 -4
- sourcecode/telemetry/__init__.py +111 -0
- sourcecode/telemetry/config.py +67 -0
- sourcecode/telemetry/consent.py +63 -0
- sourcecode/telemetry/events.py +78 -0
- sourcecode/telemetry/filters.py +140 -0
- sourcecode/telemetry/transport.py +52 -0
- sourcecode-0.27.0.dist-info/METADATA +565 -0
- {sourcecode-0.26.0.dist-info → sourcecode-0.27.0.dist-info}/RECORD +13 -6
- sourcecode-0.27.0.dist-info/licenses/LICENSE +186 -0
- sourcecode-0.26.0.dist-info/METADATA +0 -724
- {sourcecode-0.26.0.dist-info → sourcecode-0.27.0.dist-info}/WHEEL +0 -0
- {sourcecode-0.26.0.dist-info → sourcecode-0.27.0.dist-info}/entry_points.txt +0 -0
sourcecode/__init__.py
CHANGED
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="
|
|
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
|
-
"""
|
|
171
|
-
#
|
|
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.
|
|
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()
|