sourcecode 1.35.36__tar.gz → 1.36.0__tar.gz
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-1.35.36 → sourcecode-1.36.0}/PKG-INFO +3 -3
- {sourcecode-1.35.36 → sourcecode-1.36.0}/README.md +2 -2
- {sourcecode-1.35.36 → sourcecode-1.36.0}/pyproject.toml +1 -1
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/__init__.py +1 -1
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/cli.py +62 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/license.py +15 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/telemetry/__init__.py +10 -2
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/telemetry/config.py +23 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/telemetry/events.py +5 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/telemetry/filters.py +28 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/telemetry/transport.py +1 -1
- {sourcecode-1.35.36 → sourcecode-1.36.0}/supabase/functions/README.md +6 -2
- sourcecode-1.36.0/supabase/functions/telemetry/index.ts +72 -0
- sourcecode-1.36.0/supabase/sql/telemetry_events.sql +36 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/.github/workflows/build-windows.yml +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/.gitignore +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/.ruff.toml +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/CHANGELOG.md +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/CONTRIBUTING.md +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/LICENSE +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/SECURITY.md +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/raw +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/adaptive_scanner.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/architecture_analyzer.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/architecture_summary.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/ast_extractor.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/cache.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/canonical_ir.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/cir_graphs.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/classifier.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/code_notes_analyzer.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/confidence_analyzer.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/context_scorer.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/context_summarizer.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/contract_model.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/contract_pipeline.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/coverage_parser.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/dependency_analyzer.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/__init__.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/base.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/csproj_parser.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/dart.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/dotnet.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/elixir.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/go.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/heuristic.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/hybrid.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/java.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/jvm_ext.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/nodejs.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/parsers.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/php.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/project.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/python.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/ruby.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/rust.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/systems.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/terraform.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/tooling.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/doc_analyzer.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/entrypoint_classifier.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/env_analyzer.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/error_schema.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/explain.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/file_chunker.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/file_classifier.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/flow_analyzer.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/fqn_utils.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/git_analyzer.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/graph_analyzer.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/mcp/__init__.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/mcp/onboarding/__init__.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/mcp/onboarding/applier.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/mcp/onboarding/backup.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/mcp/onboarding/detector.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/mcp/onboarding/planner.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/mcp/orchestrator.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/mcp/registry.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/mcp/runner.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/mcp/server.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/mcp_nudge.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/metrics_analyzer.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/migrate_check.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/output_budget.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/path_filters.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/pr_comment_renderer.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/pr_impact.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/prepare_context.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/progress.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/ranking_engine.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/redactor.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/relevance_scorer.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/rename_refactor.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/repo_classifier.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/repository_ir.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/ris.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/runtime_classifier.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/scanner.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/schema.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/semantic_analyzer.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/serializer.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/spring_event_topology.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/spring_findings.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/spring_impact.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/spring_model.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/spring_security_audit.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/spring_semantic.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/spring_tx_analyzer.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/summarizer.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/telemetry/consent.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/tree_utils.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/workspace.py +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/supabase/functions/get-license/index.ts +0 -0
- {sourcecode-1.35.36 → sourcecode-1.36.0}/supabase/functions/lemonsqueezy-webhook/index.ts +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sourcecode
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.36.0
|
|
4
4
|
Summary: Persistent structural context and ultra-fast repeated analysis for AI coding agents
|
|
5
5
|
License-File: LICENSE
|
|
6
6
|
Keywords: agents,ai,codebase,context,developer-tools,llm
|
|
@@ -40,7 +40,7 @@ Description-Content-Type: text/markdown
|
|
|
40
40
|
|
|
41
41
|
**Persistent structural context and ultra-fast repeated analysis for AI coding agents.**
|
|
42
42
|
|
|
43
|
-

|
|
44
44
|

|
|
45
45
|
|
|
46
46
|
---
|
|
@@ -114,7 +114,7 @@ pipx install sourcecode
|
|
|
114
114
|
|
|
115
115
|
```bash
|
|
116
116
|
sourcecode version
|
|
117
|
-
# sourcecode 1.
|
|
117
|
+
# sourcecode 1.36.0
|
|
118
118
|
```
|
|
119
119
|
|
|
120
120
|
---
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**Persistent structural context and ultra-fast repeated analysis for AI coding agents.**
|
|
4
4
|
|
|
5
|
-

|
|
6
6
|

|
|
7
7
|
|
|
8
8
|
---
|
|
@@ -76,7 +76,7 @@ pipx install sourcecode
|
|
|
76
76
|
|
|
77
77
|
```bash
|
|
78
78
|
sourcecode version
|
|
79
|
-
# sourcecode 1.
|
|
79
|
+
# sourcecode 1.36.0
|
|
80
80
|
```
|
|
81
81
|
|
|
82
82
|
---
|
|
@@ -653,6 +653,38 @@ def version_callback(value: bool) -> None:
|
|
|
653
653
|
raise typer.Exit()
|
|
654
654
|
|
|
655
655
|
|
|
656
|
+
def _print_welcome() -> None:
|
|
657
|
+
"""Branded quickstart shown only on a bare invocation at a human terminal.
|
|
658
|
+
|
|
659
|
+
Agents and pipes never reach here: they either pass args/flags or stdout is
|
|
660
|
+
not a TTY, so the JSON machine contract is completely unchanged.
|
|
661
|
+
"""
|
|
662
|
+
try:
|
|
663
|
+
from sourcecode import license as _lic
|
|
664
|
+
tier = "Pro" if _lic.is_pro else "Free"
|
|
665
|
+
except Exception:
|
|
666
|
+
tier = "Free"
|
|
667
|
+
|
|
668
|
+
lines = [
|
|
669
|
+
"",
|
|
670
|
+
f" sourcecode {__version__} · {tier}",
|
|
671
|
+
"",
|
|
672
|
+
" Structural context for AI coding agents — analyze a repo, get",
|
|
673
|
+
" LLM-ready JSON in milliseconds.",
|
|
674
|
+
"",
|
|
675
|
+
" Get started:",
|
|
676
|
+
" sourcecode --compact high-signal summary of this repo",
|
|
677
|
+
" sourcecode prepare-context onboard full onboarding context",
|
|
678
|
+
" sourcecode mcp init connect Claude / Cursor",
|
|
679
|
+
"",
|
|
680
|
+
" sourcecode --help all commands",
|
|
681
|
+
]
|
|
682
|
+
if tier != "Pro":
|
|
683
|
+
lines.append(" sourcecode activate <key> unlock Pro (large repos)")
|
|
684
|
+
lines.append("")
|
|
685
|
+
typer.echo("\n".join(lines))
|
|
686
|
+
|
|
687
|
+
|
|
656
688
|
@app.callback(invoke_without_command=True)
|
|
657
689
|
def main(
|
|
658
690
|
ctx: typer.Context,
|
|
@@ -902,6 +934,13 @@ def main(
|
|
|
902
934
|
if ctx.invoked_subcommand is not None:
|
|
903
935
|
return
|
|
904
936
|
|
|
937
|
+
# Bare invocation at a human terminal → branded quickstart instead of
|
|
938
|
+
# dumping a large JSON blob. Agents/pipes are untouched: any arg/flag, or a
|
|
939
|
+
# non-TTY stdout (piped), falls through to the normal analysis + JSON.
|
|
940
|
+
if len(sys.argv) <= 1 and sys.stdout.isatty():
|
|
941
|
+
_print_welcome()
|
|
942
|
+
raise typer.Exit()
|
|
943
|
+
|
|
905
944
|
_t0 = time.monotonic()
|
|
906
945
|
no_tree: bool = False # set True by --agent; --no-tree flag removed
|
|
907
946
|
|
|
@@ -2797,6 +2836,17 @@ def prepare_context_cmd(
|
|
|
2797
2836
|
_cached_pctx = _pctx_cache.read(target, _pctx_cache_key)
|
|
2798
2837
|
if _cached_pctx is not None:
|
|
2799
2838
|
_emit_command_output(_cached_pctx, output_path, copy)
|
|
2839
|
+
try:
|
|
2840
|
+
from sourcecode import telemetry as _tel
|
|
2841
|
+
_tel.record(
|
|
2842
|
+
"execution_completed",
|
|
2843
|
+
cmd="prepare-context",
|
|
2844
|
+
feature=task,
|
|
2845
|
+
output_fmt=format,
|
|
2846
|
+
duration_s=0.0,
|
|
2847
|
+
)
|
|
2848
|
+
except Exception:
|
|
2849
|
+
pass
|
|
2800
2850
|
return
|
|
2801
2851
|
|
|
2802
2852
|
builder = TaskContextBuilder(target)
|
|
@@ -3192,6 +3242,18 @@ def prepare_context_cmd(
|
|
|
3192
3242
|
|
|
3193
3243
|
_emit_command_output(_pc_content, output_path, copy)
|
|
3194
3244
|
|
|
3245
|
+
try:
|
|
3246
|
+
from sourcecode import telemetry as _tel
|
|
3247
|
+
_tel.record(
|
|
3248
|
+
"execution_completed",
|
|
3249
|
+
cmd="prepare-context",
|
|
3250
|
+
feature=task,
|
|
3251
|
+
output_fmt=format,
|
|
3252
|
+
duration_s=_time.perf_counter() - _t0,
|
|
3253
|
+
)
|
|
3254
|
+
except Exception:
|
|
3255
|
+
pass
|
|
3256
|
+
|
|
3195
3257
|
from sourcecode.mcp_nudge import nudge_mcp_if_needed as _nudge
|
|
3196
3258
|
_nudge()
|
|
3197
3259
|
|
|
@@ -416,6 +416,15 @@ _init()
|
|
|
416
416
|
# Entitlement helpers
|
|
417
417
|
# ---------------------------------------------------------------------------
|
|
418
418
|
|
|
419
|
+
def _emit_telemetry(event: str, **kw: object) -> None:
|
|
420
|
+
"""Best-effort telemetry emit. Respects opt-in; never raises or blocks."""
|
|
421
|
+
try:
|
|
422
|
+
from sourcecode import telemetry as _tel
|
|
423
|
+
_tel.record(event, **kw) # type: ignore[arg-type]
|
|
424
|
+
except Exception:
|
|
425
|
+
pass
|
|
426
|
+
|
|
427
|
+
|
|
419
428
|
def can_use(feature_name: str) -> bool:
|
|
420
429
|
"""Return True if the current plan has access to feature_name.
|
|
421
430
|
|
|
@@ -510,6 +519,7 @@ def require_feature(
|
|
|
510
519
|
}
|
|
511
520
|
if extra_fields:
|
|
512
521
|
payload.update(extra_fields)
|
|
522
|
+
_emit_telemetry("gate_blocked", feature=feature_name, success=False)
|
|
513
523
|
_emit_upgrade_and_exit(
|
|
514
524
|
f"'{display}' is a Pro feature.",
|
|
515
525
|
[info.get("description", ""), info.get("value", "")],
|
|
@@ -560,6 +570,7 @@ def require_repo_or_pro(
|
|
|
560
570
|
}
|
|
561
571
|
if extra_fields:
|
|
562
572
|
payload.update(extra_fields)
|
|
573
|
+
_emit_telemetry("gate_blocked", feature=feature_name, repo_size="large", success=False)
|
|
563
574
|
_emit_upgrade_and_exit(headline, body, payload)
|
|
564
575
|
|
|
565
576
|
|
|
@@ -610,6 +621,7 @@ def _finish_device_auth(result: dict) -> None:
|
|
|
610
621
|
_write_license_file(data)
|
|
611
622
|
_license_data = data
|
|
612
623
|
is_pro = plan == "pro" and plan_status != "inactive"
|
|
624
|
+
_emit_telemetry("activation", feature="device_flow", success=is_pro)
|
|
613
625
|
|
|
614
626
|
sys.stderr.write(f"\n Authenticated as {email}. Plan: {plan}\n\n")
|
|
615
627
|
sys.stderr.flush()
|
|
@@ -693,9 +705,11 @@ def activate_license(license_key: str) -> None:
|
|
|
693
705
|
_fail("network_error", "Could not reach license server. Check your internet connection.")
|
|
694
706
|
|
|
695
707
|
if not result.get("valid"):
|
|
708
|
+
_emit_telemetry("activation", feature="key", success=False, error_kind="InvalidLicense")
|
|
696
709
|
_fail("invalid_license", result.get("error", "License key is not valid or subscription is inactive."))
|
|
697
710
|
|
|
698
711
|
if result.get("plan") != "pro":
|
|
712
|
+
_emit_telemetry("activation", feature="key", success=False, error_kind="NotPro")
|
|
699
713
|
_fail("not_pro", "This license is not a Pro license.")
|
|
700
714
|
|
|
701
715
|
_LICENSE_DIR.mkdir(parents=True, exist_ok=True)
|
|
@@ -709,6 +723,7 @@ def activate_license(license_key: str) -> None:
|
|
|
709
723
|
"validated_at": now,
|
|
710
724
|
}
|
|
711
725
|
_write_license_file(data)
|
|
726
|
+
_emit_telemetry("activation", feature="key", success=True)
|
|
712
727
|
|
|
713
728
|
output = {"status": "activated", "plan": "pro", "features": data["features"]}
|
|
714
729
|
sys.stdout.write(json.dumps(output, ensure_ascii=False) + "\n")
|
|
@@ -19,7 +19,7 @@ import sys
|
|
|
19
19
|
import uuid
|
|
20
20
|
from typing import Any, Optional
|
|
21
21
|
|
|
22
|
-
from sourcecode.telemetry.config import is_enabled
|
|
22
|
+
from sourcecode.telemetry.config import get_install_id, is_enabled
|
|
23
23
|
from sourcecode.telemetry.events import (
|
|
24
24
|
TelemetryEvent,
|
|
25
25
|
duration_bucket,
|
|
@@ -77,6 +77,8 @@ def record(
|
|
|
77
77
|
duration_s: Optional[float] = None,
|
|
78
78
|
success: bool = True,
|
|
79
79
|
error_kind: Optional[str] = None,
|
|
80
|
+
feature: Optional[str] = None,
|
|
81
|
+
repo_size: Optional[str] = None,
|
|
80
82
|
) -> None:
|
|
81
83
|
"""Record a telemetry event. Fire-and-forget — never blocks or raises.
|
|
82
84
|
|
|
@@ -96,10 +98,16 @@ def record(
|
|
|
96
98
|
cmd=cmd,
|
|
97
99
|
flags=flags or [],
|
|
98
100
|
output_fmt=output_fmt,
|
|
99
|
-
repo_size=
|
|
101
|
+
repo_size=(
|
|
102
|
+
repo_size if repo_size is not None
|
|
103
|
+
else file_count_bucket(file_count) if file_count is not None
|
|
104
|
+
else "unknown"
|
|
105
|
+
),
|
|
100
106
|
duration=duration_bucket(duration_s) if duration_s is not None else "unknown",
|
|
101
107
|
success=success,
|
|
102
108
|
error_kind=error_kind,
|
|
109
|
+
feature=feature,
|
|
110
|
+
install=get_install_id(),
|
|
103
111
|
session=_SESSION,
|
|
104
112
|
)
|
|
105
113
|
payload = sanitize(ev)
|
|
@@ -63,5 +63,28 @@ def mark_asked() -> None:
|
|
|
63
63
|
_save(data)
|
|
64
64
|
|
|
65
65
|
|
|
66
|
+
def get_install_id() -> str:
|
|
67
|
+
"""Stable anonymous install id — a random UUID v4.
|
|
68
|
+
|
|
69
|
+
Created lazily on first opted-in event. NOT derived from hardware, email,
|
|
70
|
+
hostname or any identifier — it only says "the same install across runs",
|
|
71
|
+
which is what enables unique-user, conversion and retention metrics.
|
|
72
|
+
Returns "" if it cannot be persisted (telemetry then degrades to events
|
|
73
|
+
without a stable id, never an error).
|
|
74
|
+
"""
|
|
75
|
+
data = _load()
|
|
76
|
+
tel = data.setdefault("telemetry", {})
|
|
77
|
+
iid = tel.get("install_id")
|
|
78
|
+
if not iid:
|
|
79
|
+
import uuid
|
|
80
|
+
iid = str(uuid.uuid4())
|
|
81
|
+
tel["install_id"] = iid
|
|
82
|
+
_save(data)
|
|
83
|
+
# If the write failed, re-read to avoid handing out a non-persisted id
|
|
84
|
+
if not _load().get("telemetry", {}).get("install_id"):
|
|
85
|
+
return ""
|
|
86
|
+
return str(iid)
|
|
87
|
+
|
|
88
|
+
|
|
66
89
|
def config_file_path() -> Path:
|
|
67
90
|
return _CONFIG_FILE
|
|
@@ -59,6 +59,9 @@ class TelemetryEvent:
|
|
|
59
59
|
duration — <1s | <5s | <15s | <60s | 60s+
|
|
60
60
|
success — True/False
|
|
61
61
|
error_kind — exception class name only (no message, no traceback)
|
|
62
|
+
feature — gated feature / task name (closed categorical set) or None
|
|
63
|
+
install — stable anonymous install UUID (random, no PII); enables
|
|
64
|
+
unique-user / conversion / retention metrics
|
|
62
65
|
session — 8-char random hex, ephemeral, NOT persisted
|
|
63
66
|
"""
|
|
64
67
|
|
|
@@ -75,4 +78,6 @@ class TelemetryEvent:
|
|
|
75
78
|
duration: str = "unknown"
|
|
76
79
|
success: bool = True
|
|
77
80
|
error_kind: Optional[str] = None
|
|
81
|
+
feature: Optional[str] = None
|
|
82
|
+
install: str = ""
|
|
78
83
|
session: str = ""
|
|
@@ -63,6 +63,19 @@ _SAFE_EVENTS: frozenset[str] = frozenset({
|
|
|
63
63
|
"execution_failed",
|
|
64
64
|
"telemetry_enabled",
|
|
65
65
|
"telemetry_disabled",
|
|
66
|
+
"gate_blocked",
|
|
67
|
+
"activation",
|
|
68
|
+
})
|
|
69
|
+
# Closed set of gated features / task names. Used to learn which capability
|
|
70
|
+
# drives Pro demand. All values are fixed product identifiers — no user data.
|
|
71
|
+
_SAFE_FEATURES: frozenset[str] = frozenset({
|
|
72
|
+
# gated features (license._FEATURE_INFO keys)
|
|
73
|
+
"impact", "modernize", "fix-bug", "review-pr", "delta", "generate-tests",
|
|
74
|
+
"--full", "git-history", "multi-repo", "export-rich", "team-snapshots",
|
|
75
|
+
# prepare-context task names not already above
|
|
76
|
+
"explain", "onboard", "refactor",
|
|
77
|
+
# activation outcomes
|
|
78
|
+
"key", "device_flow",
|
|
66
79
|
})
|
|
67
80
|
_SAFE_SIZES: frozenset[str] = frozenset({"tiny", "small", "medium", "large", "huge", "unknown"})
|
|
68
81
|
_SAFE_DURATIONS: frozenset[str] = frozenset({"<1s", "<5s", "<15s", "<60s", "60s+", "unknown"})
|
|
@@ -103,6 +116,14 @@ def _safe_session(value: str) -> str:
|
|
|
103
116
|
return ""
|
|
104
117
|
|
|
105
118
|
|
|
119
|
+
_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _safe_install(value: str) -> str:
|
|
123
|
+
"""Install id must be a canonical UUID — nothing else can pass."""
|
|
124
|
+
return value if value and _UUID_RE.match(value) else ""
|
|
125
|
+
|
|
126
|
+
|
|
106
127
|
def sanitize(event: TelemetryEvent) -> dict[str, Any]:
|
|
107
128
|
"""Apply privacy filter to event and return a safe dict for transmission.
|
|
108
129
|
|
|
@@ -127,6 +148,13 @@ def sanitize(event: TelemetryEvent) -> dict[str, Any]:
|
|
|
127
148
|
if event.error_kind:
|
|
128
149
|
safe["error_kind"] = _safe_error_kind(event.error_kind)
|
|
129
150
|
|
|
151
|
+
if event.feature:
|
|
152
|
+
safe["feature"] = _safe_str(event.feature, _SAFE_FEATURES, "other")
|
|
153
|
+
|
|
154
|
+
install = _safe_install(event.install)
|
|
155
|
+
if install:
|
|
156
|
+
safe["install"] = install
|
|
157
|
+
|
|
130
158
|
session = _safe_session(event.session)
|
|
131
159
|
if session:
|
|
132
160
|
safe["session"] = session
|
|
@@ -9,9 +9,13 @@ Backend for the Pro license flow. The CLI side lives in
|
|
|
9
9
|
|----------|---------|-----|
|
|
10
10
|
| `get-license` | Validates a license key for `sourcecode activate` and the 30-min revalidation. Returns `{valid, plan, status, features, email}`. | `--no-verify-jwt` |
|
|
11
11
|
| `lemonsqueezy-webhook` | Lemon Squeezy purchase/subscription webhook. Stores the LS native key, sets plan/status, handles revocation. | `--no-verify-jwt` |
|
|
12
|
+
| `telemetry` | Collects opt-in anonymous usage events (no PII). Inserts into `telemetry_events`. CLI side: `src/sourcecode/telemetry/`. | `--no-verify-jwt` |
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
publishable key (not a JWT),
|
|
14
|
+
All deploy with JWT verification OFF: the CLI authenticates with the public
|
|
15
|
+
publishable key (not a JWT), the webhook authenticates via HMAC signature, and
|
|
16
|
+
telemetry is unauthenticated public ingest.
|
|
17
|
+
|
|
18
|
+
The `telemetry` table is created from `supabase/sql/telemetry_events.sql`.
|
|
15
19
|
|
|
16
20
|
## Secrets (Supabase dashboard -> Edge Functions -> Secrets)
|
|
17
21
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { serve } from "https://deno.land/std@0.177.0/http/server.ts";
|
|
2
|
+
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
|
3
|
+
|
|
4
|
+
// Opt-in anonymous usage telemetry collector. The CLI sends privacy-filtered
|
|
5
|
+
// events (no PII, no paths) fire-and-forget — see src/sourcecode/telemetry/.
|
|
6
|
+
// Deploy with --no-verify-jwt: events are public, low-value, and the client
|
|
7
|
+
// sends no auth header. This endpoint only INSERTS into telemetry_events.
|
|
8
|
+
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
|
|
9
|
+
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
|
10
|
+
|
|
11
|
+
// Server-side defense in depth — mirror the client allowlists. Anything
|
|
12
|
+
// unexpected is coerced to a safe default so a tampered payload cannot inject.
|
|
13
|
+
const EVENTS = new Set([
|
|
14
|
+
"command_executed", "execution_completed", "execution_failed",
|
|
15
|
+
"telemetry_enabled", "telemetry_disabled", "gate_blocked", "activation",
|
|
16
|
+
]);
|
|
17
|
+
const SIZES = new Set(["tiny", "small", "medium", "large", "huge", "unknown"]);
|
|
18
|
+
const DURATIONS = new Set(["<1s", "<5s", "<15s", "<60s", "60s+", "unknown"]);
|
|
19
|
+
const OSES = new Set(["linux", "macos", "windows", "other"]);
|
|
20
|
+
const ARCHES = new Set(["x64", "arm64", "other"]);
|
|
21
|
+
const FMTS = new Set(["json", "yaml"]);
|
|
22
|
+
|
|
23
|
+
const pick = (v: unknown, allowed: Set<string>, fb: string) =>
|
|
24
|
+
typeof v === "string" && allowed.has(v) ? v : fb;
|
|
25
|
+
const short = (v: unknown, n = 32) =>
|
|
26
|
+
typeof v === "string" && v.length <= n && !/[/\\\s]/.test(v) ? v : null;
|
|
27
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
28
|
+
const uuid = (v: unknown) =>
|
|
29
|
+
typeof v === "string" && UUID_RE.test(v) ? v : null;
|
|
30
|
+
|
|
31
|
+
serve(async (req: Request) => {
|
|
32
|
+
if (req.method !== "POST") return new Response("ok", { status: 405 });
|
|
33
|
+
|
|
34
|
+
let p: Record<string, unknown>;
|
|
35
|
+
try {
|
|
36
|
+
p = await req.json();
|
|
37
|
+
} catch {
|
|
38
|
+
return new Response("ok", { status: 200 }); // never error loudly on telemetry
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const row = {
|
|
42
|
+
event: pick(p.event, EVENTS, "command_executed"),
|
|
43
|
+
client_ts: short(p.ts, 20),
|
|
44
|
+
v: short(p.v, 16),
|
|
45
|
+
py: short(p.py, 8),
|
|
46
|
+
os: pick(p.os, OSES, "other"),
|
|
47
|
+
arch: pick(p.arch, ARCHES, "other"),
|
|
48
|
+
cmd: short(p.cmd, 24),
|
|
49
|
+
flags: Array.isArray(p.flags) ? p.flags.filter((f) => short(f, 32)).slice(0, 40) : [],
|
|
50
|
+
output_fmt: pick(p.output_fmt, FMTS, "json"),
|
|
51
|
+
repo_size: pick(p.repo_size, SIZES, "unknown"),
|
|
52
|
+
duration: pick(p.duration, DURATIONS, "unknown"),
|
|
53
|
+
success: typeof p.success === "boolean" ? p.success : null,
|
|
54
|
+
error_kind: short(p.error_kind, 64),
|
|
55
|
+
feature: short(p.feature, 32),
|
|
56
|
+
install_id: uuid(p.install),
|
|
57
|
+
session: short(p.session, 16),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
|
|
62
|
+
await supabase.from("telemetry_events").insert(row);
|
|
63
|
+
} catch (e) {
|
|
64
|
+
console.error("telemetry insert failed", e);
|
|
65
|
+
// still return 200 — never penalize the CLI for our DB hiccup
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return new Response(JSON.stringify({ received: true }), {
|
|
69
|
+
status: 200,
|
|
70
|
+
headers: { "Content-Type": "application/json" },
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
-- Anonymous, opt-in usage telemetry. No PII, no paths — every column is a
|
|
2
|
+
-- bounded categorical or bucket. Populated by the `telemetry` edge function.
|
|
3
|
+
create table if not exists public.telemetry_events (
|
|
4
|
+
id uuid primary key default gen_random_uuid(),
|
|
5
|
+
received_at timestamptz not null default now(),
|
|
6
|
+
event text not null,
|
|
7
|
+
client_ts text,
|
|
8
|
+
v text, -- sourcecode version
|
|
9
|
+
py text, -- python major.minor
|
|
10
|
+
os text, -- linux | macos | windows | other
|
|
11
|
+
arch text, -- x64 | arm64 | other
|
|
12
|
+
cmd text, -- analyze | prepare-context | telemetry | unknown
|
|
13
|
+
flags jsonb default '[]'::jsonb,
|
|
14
|
+
output_fmt text, -- json | yaml
|
|
15
|
+
repo_size text, -- tiny | small | medium | large | huge | unknown
|
|
16
|
+
duration text, -- <1s | <5s | <15s | <60s | 60s+ | unknown
|
|
17
|
+
success boolean,
|
|
18
|
+
error_kind text, -- exception class name only
|
|
19
|
+
feature text, -- gated feature / task name (closed set)
|
|
20
|
+
install_id uuid, -- stable anonymous install id (random, no PII)
|
|
21
|
+
session text -- ephemeral 8-char hex, NOT a stable user id
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
-- If the table already exists from an earlier deploy, add the column:
|
|
25
|
+
alter table public.telemetry_events add column if not exists install_id uuid;
|
|
26
|
+
|
|
27
|
+
-- Common query axes: funnel by event, adoption by version, time series,
|
|
28
|
+
-- and unique-install / conversion / retention by install_id.
|
|
29
|
+
create index if not exists telemetry_events_event_idx on public.telemetry_events (event);
|
|
30
|
+
create index if not exists telemetry_events_received_at_idx on public.telemetry_events (received_at);
|
|
31
|
+
create index if not exists telemetry_events_feature_idx on public.telemetry_events (feature);
|
|
32
|
+
create index if not exists telemetry_events_install_idx on public.telemetry_events (install_id);
|
|
33
|
+
|
|
34
|
+
-- RLS on, no policies: only the service role (edge function) can write/read.
|
|
35
|
+
-- The public anon/publishable key cannot touch this table.
|
|
36
|
+
alter table public.telemetry_events enable row level security;
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|