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.
Files changed (114) hide show
  1. {sourcecode-1.35.36 → sourcecode-1.36.0}/PKG-INFO +3 -3
  2. {sourcecode-1.35.36 → sourcecode-1.36.0}/README.md +2 -2
  3. {sourcecode-1.35.36 → sourcecode-1.36.0}/pyproject.toml +1 -1
  4. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/__init__.py +1 -1
  5. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/cli.py +62 -0
  6. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/license.py +15 -0
  7. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/telemetry/__init__.py +10 -2
  8. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/telemetry/config.py +23 -0
  9. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/telemetry/events.py +5 -0
  10. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/telemetry/filters.py +28 -0
  11. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/telemetry/transport.py +1 -1
  12. {sourcecode-1.35.36 → sourcecode-1.36.0}/supabase/functions/README.md +6 -2
  13. sourcecode-1.36.0/supabase/functions/telemetry/index.ts +72 -0
  14. sourcecode-1.36.0/supabase/sql/telemetry_events.sql +36 -0
  15. {sourcecode-1.35.36 → sourcecode-1.36.0}/.github/workflows/build-windows.yml +0 -0
  16. {sourcecode-1.35.36 → sourcecode-1.36.0}/.gitignore +0 -0
  17. {sourcecode-1.35.36 → sourcecode-1.36.0}/.ruff.toml +0 -0
  18. {sourcecode-1.35.36 → sourcecode-1.36.0}/CHANGELOG.md +0 -0
  19. {sourcecode-1.35.36 → sourcecode-1.36.0}/CONTRIBUTING.md +0 -0
  20. {sourcecode-1.35.36 → sourcecode-1.36.0}/LICENSE +0 -0
  21. {sourcecode-1.35.36 → sourcecode-1.36.0}/SECURITY.md +0 -0
  22. {sourcecode-1.35.36 → sourcecode-1.36.0}/raw +0 -0
  23. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/adaptive_scanner.py +0 -0
  24. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/architecture_analyzer.py +0 -0
  25. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/architecture_summary.py +0 -0
  26. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/ast_extractor.py +0 -0
  27. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/cache.py +0 -0
  28. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/canonical_ir.py +0 -0
  29. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/cir_graphs.py +0 -0
  30. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/classifier.py +0 -0
  31. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/code_notes_analyzer.py +0 -0
  32. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/confidence_analyzer.py +0 -0
  33. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/context_scorer.py +0 -0
  34. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/context_summarizer.py +0 -0
  35. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/contract_model.py +0 -0
  36. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/contract_pipeline.py +0 -0
  37. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/coverage_parser.py +0 -0
  38. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/dependency_analyzer.py +0 -0
  39. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/__init__.py +0 -0
  40. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/base.py +0 -0
  41. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/csproj_parser.py +0 -0
  42. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/dart.py +0 -0
  43. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/dotnet.py +0 -0
  44. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/elixir.py +0 -0
  45. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/go.py +0 -0
  46. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/heuristic.py +0 -0
  47. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/hybrid.py +0 -0
  48. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/java.py +0 -0
  49. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/jvm_ext.py +0 -0
  50. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/nodejs.py +0 -0
  51. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/parsers.py +0 -0
  52. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/php.py +0 -0
  53. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/project.py +0 -0
  54. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/python.py +0 -0
  55. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/ruby.py +0 -0
  56. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/rust.py +0 -0
  57. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/systems.py +0 -0
  58. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/terraform.py +0 -0
  59. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/detectors/tooling.py +0 -0
  60. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/doc_analyzer.py +0 -0
  61. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/entrypoint_classifier.py +0 -0
  62. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/env_analyzer.py +0 -0
  63. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/error_schema.py +0 -0
  64. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/explain.py +0 -0
  65. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/file_chunker.py +0 -0
  66. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/file_classifier.py +0 -0
  67. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/flow_analyzer.py +0 -0
  68. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/fqn_utils.py +0 -0
  69. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/git_analyzer.py +0 -0
  70. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/graph_analyzer.py +0 -0
  71. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/mcp/__init__.py +0 -0
  72. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/mcp/onboarding/__init__.py +0 -0
  73. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/mcp/onboarding/applier.py +0 -0
  74. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/mcp/onboarding/backup.py +0 -0
  75. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/mcp/onboarding/detector.py +0 -0
  76. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/mcp/onboarding/planner.py +0 -0
  77. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/mcp/orchestrator.py +0 -0
  78. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/mcp/registry.py +0 -0
  79. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/mcp/runner.py +0 -0
  80. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/mcp/server.py +0 -0
  81. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/mcp_nudge.py +0 -0
  82. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/metrics_analyzer.py +0 -0
  83. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/migrate_check.py +0 -0
  84. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/output_budget.py +0 -0
  85. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/path_filters.py +0 -0
  86. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/pr_comment_renderer.py +0 -0
  87. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/pr_impact.py +0 -0
  88. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/prepare_context.py +0 -0
  89. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/progress.py +0 -0
  90. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/ranking_engine.py +0 -0
  91. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/redactor.py +0 -0
  92. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/relevance_scorer.py +0 -0
  93. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/rename_refactor.py +0 -0
  94. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/repo_classifier.py +0 -0
  95. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/repository_ir.py +0 -0
  96. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/ris.py +0 -0
  97. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/runtime_classifier.py +0 -0
  98. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/scanner.py +0 -0
  99. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/schema.py +0 -0
  100. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/semantic_analyzer.py +0 -0
  101. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/serializer.py +0 -0
  102. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/spring_event_topology.py +0 -0
  103. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/spring_findings.py +0 -0
  104. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/spring_impact.py +0 -0
  105. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/spring_model.py +0 -0
  106. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/spring_security_audit.py +0 -0
  107. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/spring_semantic.py +0 -0
  108. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/spring_tx_analyzer.py +0 -0
  109. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/summarizer.py +0 -0
  110. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/telemetry/consent.py +0 -0
  111. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/tree_utils.py +0 -0
  112. {sourcecode-1.35.36 → sourcecode-1.36.0}/src/sourcecode/workspace.py +0 -0
  113. {sourcecode-1.35.36 → sourcecode-1.36.0}/supabase/functions/get-license/index.ts +0 -0
  114. {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.35.36
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
- ![Version](https://img.shields.io/badge/version-1.35.36-blue)
43
+ ![Version](https://img.shields.io/badge/version-1.36.0-blue)
44
44
  ![Python](https://img.shields.io/badge/python-3.10%2B-green)
45
45
 
46
46
  ---
@@ -114,7 +114,7 @@ pipx install sourcecode
114
114
 
115
115
  ```bash
116
116
  sourcecode version
117
- # sourcecode 1.35.36
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
- ![Version](https://img.shields.io/badge/version-1.35.36-blue)
5
+ ![Version](https://img.shields.io/badge/version-1.36.0-blue)
6
6
  ![Python](https://img.shields.io/badge/python-3.10%2B-green)
7
7
 
8
8
  ---
@@ -76,7 +76,7 @@ pipx install sourcecode
76
76
 
77
77
  ```bash
78
78
  sourcecode version
79
- # sourcecode 1.35.36
79
+ # sourcecode 1.36.0
80
80
  ```
81
81
 
82
82
  ---
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sourcecode"
7
- version = "1.35.36"
7
+ version = "1.36.0"
8
8
  description = "Persistent structural context and ultra-fast repeated analysis for AI coding agents"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,3 +1,3 @@
1
1
  """sourcecode — Deterministic codebase context maps for AI coding agents."""
2
2
 
3
- __version__ = "1.35.36"
3
+ __version__ = "1.36.0"
@@ -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=file_count_bucket(file_count) if file_count is not None else "unknown",
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
@@ -15,7 +15,7 @@ import os
15
15
  import threading
16
16
  from typing import Any
17
17
 
18
- _DEFAULT_ENDPOINT = "https://t.sourcecode.dev/v1/event"
18
+ _DEFAULT_ENDPOINT = "https://qkndlmyekvujjdgthtmz.supabase.co/functions/v1/telemetry"
19
19
  _TIMEOUT_S = 3
20
20
 
21
21
 
@@ -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
- Both deploy with JWT verification OFF: the CLI authenticates with the public
14
- publishable key (not a JWT), and the webhook authenticates via HMAC signature.
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