axor-telemetry 0.1.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.
- axor_telemetry-0.1.0/.github/workflows/ci.yml +88 -0
- axor_telemetry-0.1.0/.gitignore +8 -0
- axor_telemetry-0.1.0/LICENSE +21 -0
- axor_telemetry-0.1.0/PKG-INFO +80 -0
- axor_telemetry-0.1.0/README.md +55 -0
- axor_telemetry-0.1.0/axor_telemetry/__init__.py +31 -0
- axor_telemetry-0.1.0/axor_telemetry/__main__.py +4 -0
- axor_telemetry-0.1.0/axor_telemetry/cli.py +165 -0
- axor_telemetry-0.1.0/axor_telemetry/config.py +145 -0
- axor_telemetry-0.1.0/axor_telemetry/embedder.py +102 -0
- axor_telemetry-0.1.0/axor_telemetry/pipeline.py +222 -0
- axor_telemetry-0.1.0/axor_telemetry/serialize.py +58 -0
- axor_telemetry-0.1.0/axor_telemetry/sinks/__init__.py +4 -0
- axor_telemetry-0.1.0/axor_telemetry/sinks/file_sink.py +123 -0
- axor_telemetry-0.1.0/axor_telemetry/sinks/http_sink.py +119 -0
- axor_telemetry-0.1.0/pyproject.toml +39 -0
- axor_telemetry-0.1.0/tests/__init__.py +0 -0
- axor_telemetry-0.1.0/tests/conftest.py +17 -0
- axor_telemetry-0.1.0/tests/test_cli.py +86 -0
- axor_telemetry-0.1.0/tests/test_config.py +65 -0
- axor_telemetry-0.1.0/tests/test_embedder.py +59 -0
- axor_telemetry-0.1.0/tests/test_file_sink.py +81 -0
- axor_telemetry-0.1.0/tests/test_http_sink.py +205 -0
- axor_telemetry-0.1.0/tests/test_main_module.py +30 -0
- axor_telemetry-0.1.0/tests/test_pipeline.py +224 -0
- axor_telemetry-0.1.0/tests/test_serialize.py +120 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
name: CI/CD
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
tags: ["v*.*.*"]
|
|
7
|
+
pull_request:
|
|
8
|
+
branches: [main]
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
test:
|
|
12
|
+
name: Test (Python ${{ matrix.python-version }})
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
strategy:
|
|
15
|
+
matrix:
|
|
16
|
+
python-version: ["3.11", "3.12"]
|
|
17
|
+
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v4
|
|
20
|
+
|
|
21
|
+
- name: Checkout axor-core
|
|
22
|
+
uses: actions/checkout@v4
|
|
23
|
+
with:
|
|
24
|
+
repository: ${{ github.repository_owner }}/axor-core
|
|
25
|
+
path: axor-core
|
|
26
|
+
|
|
27
|
+
- uses: actions/setup-python@v5
|
|
28
|
+
with:
|
|
29
|
+
python-version: ${{ matrix.python-version }}
|
|
30
|
+
cache: pip
|
|
31
|
+
|
|
32
|
+
- name: Install
|
|
33
|
+
run: |
|
|
34
|
+
pip install -e axor-core/
|
|
35
|
+
pip install -e ".[dev]"
|
|
36
|
+
|
|
37
|
+
- name: Run tests
|
|
38
|
+
run: pytest tests/ -v --tb=short
|
|
39
|
+
|
|
40
|
+
publish:
|
|
41
|
+
name: Publish to PyPI
|
|
42
|
+
needs: test
|
|
43
|
+
runs-on: ubuntu-latest
|
|
44
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
45
|
+
environment: pypi
|
|
46
|
+
|
|
47
|
+
permissions:
|
|
48
|
+
id-token: write
|
|
49
|
+
|
|
50
|
+
steps:
|
|
51
|
+
- uses: actions/checkout@v4
|
|
52
|
+
|
|
53
|
+
- uses: actions/setup-python@v5
|
|
54
|
+
with:
|
|
55
|
+
python-version: "3.12"
|
|
56
|
+
|
|
57
|
+
- name: Verify tag matches package version
|
|
58
|
+
run: |
|
|
59
|
+
python - << 'EOF'
|
|
60
|
+
import pathlib
|
|
61
|
+
import re
|
|
62
|
+
import sys
|
|
63
|
+
import tomllib
|
|
64
|
+
|
|
65
|
+
ref = "${{ github.ref_name }}"
|
|
66
|
+
m = re.fullmatch(r"v(\d+\.\d+\.\d+)", ref)
|
|
67
|
+
if not m:
|
|
68
|
+
print(f"Tag {ref!r} must match vX.Y.Z")
|
|
69
|
+
sys.exit(1)
|
|
70
|
+
|
|
71
|
+
tag_version = m.group(1)
|
|
72
|
+
data = tomllib.loads(pathlib.Path("pyproject.toml").read_text(encoding="utf-8"))
|
|
73
|
+
pkg_version = data["project"]["version"]
|
|
74
|
+
|
|
75
|
+
if tag_version != pkg_version:
|
|
76
|
+
print(f"Version mismatch: tag={tag_version}, pyproject={pkg_version}")
|
|
77
|
+
sys.exit(1)
|
|
78
|
+
|
|
79
|
+
print(f"Version check passed: {pkg_version}")
|
|
80
|
+
EOF
|
|
81
|
+
|
|
82
|
+
- name: Build
|
|
83
|
+
run: |
|
|
84
|
+
pip install hatchling build
|
|
85
|
+
python -m build
|
|
86
|
+
|
|
87
|
+
- name: Publish to PyPI
|
|
88
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Axor Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: axor-telemetry
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Anonymous telemetry pipeline for axor-core: MinHash embedder, local/HTTP sinks, opt-in consent CLI
|
|
5
|
+
Project-URL: Repository, https://github.com/Bucha11/axor-telemetry
|
|
6
|
+
Project-URL: Bug Tracker, https://github.com/Bucha11/axor-telemetry/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/Bucha11/axor-telemetry/releases
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agents,axor,minhash,telemetry
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Provides-Extra: core
|
|
19
|
+
Requires-Dist: axor-core>=0.3.0; extra == 'core'
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: axor-core>=0.3.0; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# axor-telemetry
|
|
27
|
+
|
|
28
|
+
Anonymous telemetry pipeline for [axor-core](https://github.com/Bucha11/axor-core).
|
|
29
|
+
|
|
30
|
+
**Opt-in only.** Nothing is sent without explicit user consent.
|
|
31
|
+
|
|
32
|
+
## What gets sent (when consent is given)
|
|
33
|
+
|
|
34
|
+
- `signal_chosen` (e.g. `focused_generative`)
|
|
35
|
+
- `classifier_used`, `confidence`
|
|
36
|
+
- MinHash fingerprint of the raw input (128 ints, non-reversible)
|
|
37
|
+
- `tokens_spent`, `policy_adjusted`
|
|
38
|
+
- `axor_version`
|
|
39
|
+
|
|
40
|
+
**Not sent:** raw task text, file contents, user or session identifiers,
|
|
41
|
+
tool arguments, secrets.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install axor-telemetry[core]
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## CLI
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
python -m axor_telemetry consent # interactive opt-in
|
|
53
|
+
python -m axor_telemetry status # show current config
|
|
54
|
+
python -m axor_telemetry preview # show the last queued record
|
|
55
|
+
python -m axor_telemetry on # non-interactive: set local mode
|
|
56
|
+
python -m axor_telemetry off # disable
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Config lives at `~/.axor/config.toml` under `[telemetry]`.
|
|
60
|
+
|
|
61
|
+
## Modes
|
|
62
|
+
|
|
63
|
+
| mode | behavior |
|
|
64
|
+
|----------|----------|
|
|
65
|
+
| `off` | Default. Pipeline does nothing. |
|
|
66
|
+
| `local` | Writes to `~/.axor/telemetry_queue.jsonl`. Never sent anywhere. |
|
|
67
|
+
| `remote` | Writes local queue + ships batches to `telemetry.useaxor.net/v1/records`. Retry-on-next-start if offline. |
|
|
68
|
+
|
|
69
|
+
## Programmatic usage
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from axor_telemetry import TelemetryPipeline, MinHashEmbedder, FileTelemetrySink
|
|
73
|
+
|
|
74
|
+
pipeline = TelemetryPipeline(
|
|
75
|
+
embedder=MinHashEmbedder(),
|
|
76
|
+
sink=FileTelemetrySink(queue_path="~/.axor/telemetry_queue.jsonl"),
|
|
77
|
+
)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Inject `pipeline` into `GovernedSession` (see axor-cli integration).
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# axor-telemetry
|
|
2
|
+
|
|
3
|
+
Anonymous telemetry pipeline for [axor-core](https://github.com/Bucha11/axor-core).
|
|
4
|
+
|
|
5
|
+
**Opt-in only.** Nothing is sent without explicit user consent.
|
|
6
|
+
|
|
7
|
+
## What gets sent (when consent is given)
|
|
8
|
+
|
|
9
|
+
- `signal_chosen` (e.g. `focused_generative`)
|
|
10
|
+
- `classifier_used`, `confidence`
|
|
11
|
+
- MinHash fingerprint of the raw input (128 ints, non-reversible)
|
|
12
|
+
- `tokens_spent`, `policy_adjusted`
|
|
13
|
+
- `axor_version`
|
|
14
|
+
|
|
15
|
+
**Not sent:** raw task text, file contents, user or session identifiers,
|
|
16
|
+
tool arguments, secrets.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install axor-telemetry[core]
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## CLI
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
python -m axor_telemetry consent # interactive opt-in
|
|
28
|
+
python -m axor_telemetry status # show current config
|
|
29
|
+
python -m axor_telemetry preview # show the last queued record
|
|
30
|
+
python -m axor_telemetry on # non-interactive: set local mode
|
|
31
|
+
python -m axor_telemetry off # disable
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Config lives at `~/.axor/config.toml` under `[telemetry]`.
|
|
35
|
+
|
|
36
|
+
## Modes
|
|
37
|
+
|
|
38
|
+
| mode | behavior |
|
|
39
|
+
|----------|----------|
|
|
40
|
+
| `off` | Default. Pipeline does nothing. |
|
|
41
|
+
| `local` | Writes to `~/.axor/telemetry_queue.jsonl`. Never sent anywhere. |
|
|
42
|
+
| `remote` | Writes local queue + ships batches to `telemetry.useaxor.net/v1/records`. Retry-on-next-start if offline. |
|
|
43
|
+
|
|
44
|
+
## Programmatic usage
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from axor_telemetry import TelemetryPipeline, MinHashEmbedder, FileTelemetrySink
|
|
48
|
+
|
|
49
|
+
pipeline = TelemetryPipeline(
|
|
50
|
+
embedder=MinHashEmbedder(),
|
|
51
|
+
sink=FileTelemetrySink(queue_path="~/.axor/telemetry_queue.jsonl"),
|
|
52
|
+
)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Inject `pipeline` into `GovernedSession` (see axor-cli integration).
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
axor-telemetry
|
|
3
|
+
──────────────
|
|
4
|
+
Anonymous telemetry pipeline for axor-core. Opt-in only.
|
|
5
|
+
|
|
6
|
+
Public surface:
|
|
7
|
+
- MinHashEmbedder — pure-Python 128-dim char-3 MinHash fingerprint
|
|
8
|
+
- FileTelemetrySink — JSONL queue at ~/.axor/telemetry_queue.jsonl
|
|
9
|
+
- HTTPTelemetrySink — POST batches with retry-on-next-start
|
|
10
|
+
- TelemetryPipeline — trace → anonymize → dispatch
|
|
11
|
+
- TelemetryConfig — resolved mode + endpoint from env + config.toml
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from axor_telemetry.embedder import MinHashEmbedder
|
|
15
|
+
from axor_telemetry.sinks.file_sink import FileTelemetrySink
|
|
16
|
+
from axor_telemetry.sinks.http_sink import HTTPTelemetrySink
|
|
17
|
+
from axor_telemetry.pipeline import TelemetryPipeline, build_pipeline
|
|
18
|
+
from axor_telemetry.config import TelemetryConfig, TelemetryMode
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"MinHashEmbedder",
|
|
24
|
+
"FileTelemetrySink",
|
|
25
|
+
"HTTPTelemetrySink",
|
|
26
|
+
"TelemetryPipeline",
|
|
27
|
+
"build_pipeline",
|
|
28
|
+
"TelemetryConfig",
|
|
29
|
+
"TelemetryMode",
|
|
30
|
+
"__version__",
|
|
31
|
+
]
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI for axor-telemetry: `python -m axor_telemetry <command>`.
|
|
3
|
+
|
|
4
|
+
Commands:
|
|
5
|
+
consent — interactive opt-in; writes mode + endpoint to ~/.axor/config.toml
|
|
6
|
+
status — show current effective config (env + file merged)
|
|
7
|
+
preview — print the last queued record so the user sees exactly what goes out
|
|
8
|
+
on — non-interactive: set mode=local
|
|
9
|
+
off — disable telemetry
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from axor_telemetry.config import TelemetryConfig, TelemetryMode
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def cmd_consent(args: argparse.Namespace, stream=sys.stdout, prompt_input=input) -> int:
|
|
22
|
+
current = TelemetryConfig.load()
|
|
23
|
+
stream.write(_consent_text(current))
|
|
24
|
+
stream.flush()
|
|
25
|
+
try:
|
|
26
|
+
answer = prompt_input("> ").strip().lower()
|
|
27
|
+
except (EOFError, KeyboardInterrupt):
|
|
28
|
+
stream.write("\naborted. no change.\n")
|
|
29
|
+
return 1
|
|
30
|
+
|
|
31
|
+
if answer in ("r", "remote"):
|
|
32
|
+
new = TelemetryConfig(mode=TelemetryMode.REMOTE, endpoint=current.endpoint,
|
|
33
|
+
queue_path=current.queue_path,
|
|
34
|
+
fingerprint_kind=current.fingerprint_kind)
|
|
35
|
+
elif answer in ("l", "local"):
|
|
36
|
+
new = TelemetryConfig(mode=TelemetryMode.LOCAL, endpoint=current.endpoint,
|
|
37
|
+
queue_path=current.queue_path,
|
|
38
|
+
fingerprint_kind=current.fingerprint_kind)
|
|
39
|
+
else:
|
|
40
|
+
new = TelemetryConfig(mode=TelemetryMode.OFF, endpoint=current.endpoint,
|
|
41
|
+
queue_path=current.queue_path,
|
|
42
|
+
fingerprint_kind=current.fingerprint_kind)
|
|
43
|
+
|
|
44
|
+
path = new.write()
|
|
45
|
+
stream.write(f"saved to {path}: mode={new.mode.value}\n")
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def cmd_status(args: argparse.Namespace, stream=sys.stdout) -> int:
|
|
50
|
+
cfg = TelemetryConfig.load()
|
|
51
|
+
stream.write(f"mode: {cfg.mode.value}\n")
|
|
52
|
+
stream.write(f"endpoint: {cfg.endpoint}\n")
|
|
53
|
+
stream.write(f"queue_path: {cfg.queue_path}\n")
|
|
54
|
+
stream.write(f"fingerprint_kind: {cfg.fingerprint_kind}\n")
|
|
55
|
+
queue = Path(cfg.queue_path).expanduser()
|
|
56
|
+
if queue.is_file():
|
|
57
|
+
try:
|
|
58
|
+
size = queue.stat().st_size
|
|
59
|
+
except OSError:
|
|
60
|
+
size = 0
|
|
61
|
+
stream.write(f"queue_bytes: {size}\n")
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def cmd_preview(args: argparse.Namespace, stream=sys.stdout) -> int:
|
|
66
|
+
cfg = TelemetryConfig.load()
|
|
67
|
+
queue = Path(cfg.queue_path).expanduser()
|
|
68
|
+
if not queue.is_file():
|
|
69
|
+
stream.write("queue is empty (no records have been generated yet).\n")
|
|
70
|
+
return 0
|
|
71
|
+
last = None
|
|
72
|
+
try:
|
|
73
|
+
with queue.open("r", encoding="utf-8") as f:
|
|
74
|
+
for line in f:
|
|
75
|
+
line = line.strip()
|
|
76
|
+
if line:
|
|
77
|
+
last = line
|
|
78
|
+
except OSError as exc:
|
|
79
|
+
stream.write(f"failed to read queue: {exc}\n")
|
|
80
|
+
return 1
|
|
81
|
+
if not last:
|
|
82
|
+
stream.write("queue file exists but is empty.\n")
|
|
83
|
+
return 0
|
|
84
|
+
try:
|
|
85
|
+
record = json.loads(last)
|
|
86
|
+
stream.write(json.dumps(record, indent=2, ensure_ascii=False) + "\n")
|
|
87
|
+
except json.JSONDecodeError:
|
|
88
|
+
stream.write(last + "\n")
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def cmd_on(args: argparse.Namespace, stream=sys.stdout) -> int:
|
|
93
|
+
current = TelemetryConfig.load()
|
|
94
|
+
mode = TelemetryMode.REMOTE if args.remote else TelemetryMode.LOCAL
|
|
95
|
+
new = TelemetryConfig(mode=mode, endpoint=current.endpoint,
|
|
96
|
+
queue_path=current.queue_path,
|
|
97
|
+
fingerprint_kind=current.fingerprint_kind)
|
|
98
|
+
path = new.write()
|
|
99
|
+
stream.write(f"telemetry is now {mode.value}. config: {path}\n")
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def cmd_off(args: argparse.Namespace, stream=sys.stdout) -> int:
|
|
104
|
+
current = TelemetryConfig.load()
|
|
105
|
+
new = TelemetryConfig(mode=TelemetryMode.OFF, endpoint=current.endpoint,
|
|
106
|
+
queue_path=current.queue_path,
|
|
107
|
+
fingerprint_kind=current.fingerprint_kind)
|
|
108
|
+
path = new.write()
|
|
109
|
+
stream.write(f"telemetry disabled. config: {path}\n")
|
|
110
|
+
return 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _consent_text(current: TelemetryConfig) -> str:
|
|
114
|
+
return (
|
|
115
|
+
"axor telemetry is opt-in. Nothing has been sent yet.\n"
|
|
116
|
+
"\n"
|
|
117
|
+
"What gets collected (when enabled):\n"
|
|
118
|
+
" - chosen classification (e.g. focused_generative)\n"
|
|
119
|
+
" - classifier name + confidence\n"
|
|
120
|
+
" - a 128-int MinHash fingerprint of the raw input (non-reversible)\n"
|
|
121
|
+
" - tokens spent, whether policy was adjusted mid-run\n"
|
|
122
|
+
"\n"
|
|
123
|
+
"What is NEVER collected:\n"
|
|
124
|
+
" - raw task text, file contents, tool arguments, secrets\n"
|
|
125
|
+
" - user/session identifiers\n"
|
|
126
|
+
"\n"
|
|
127
|
+
f"Current mode: {current.mode.value}\n"
|
|
128
|
+
f"Endpoint (remote mode): {current.endpoint}\n"
|
|
129
|
+
"\n"
|
|
130
|
+
"Choose:\n"
|
|
131
|
+
" [l] local — write records to a local JSONL queue, never send anywhere\n"
|
|
132
|
+
" [r] remote — also ship to the project telemetry server (retry on next start if offline)\n"
|
|
133
|
+
" [n] off — do nothing (default)\n"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
138
|
+
p = argparse.ArgumentParser(prog="python -m axor_telemetry")
|
|
139
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
140
|
+
|
|
141
|
+
sub.add_parser("consent", help="interactive opt-in prompt")
|
|
142
|
+
sub.add_parser("status", help="show current effective config")
|
|
143
|
+
sub.add_parser("preview", help="print the last queued record")
|
|
144
|
+
on_parser = sub.add_parser("on", help="enable telemetry (local by default)")
|
|
145
|
+
on_parser.add_argument("--remote", action="store_true", help="enable remote shipping")
|
|
146
|
+
sub.add_parser("off", help="disable telemetry")
|
|
147
|
+
|
|
148
|
+
return p
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def main(argv: list[str] | None = None) -> int:
|
|
152
|
+
parser = build_parser()
|
|
153
|
+
args = parser.parse_args(argv)
|
|
154
|
+
dispatch = {
|
|
155
|
+
"consent": cmd_consent,
|
|
156
|
+
"status": cmd_status,
|
|
157
|
+
"preview": cmd_preview,
|
|
158
|
+
"on": cmd_on,
|
|
159
|
+
"off": cmd_off,
|
|
160
|
+
}
|
|
161
|
+
return dispatch[args.cmd](args)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
if __name__ == "__main__":
|
|
165
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Telemetry configuration — resolved from env and ~/.axor/config.toml.
|
|
3
|
+
|
|
4
|
+
Priority (highest first):
|
|
5
|
+
1. env vars AXOR_TELEMETRY, AXOR_TELEMETRY_ENDPOINT, AXOR_TELEMETRY_QUEUE
|
|
6
|
+
2. ~/.axor/config.toml [telemetry] section
|
|
7
|
+
3. defaults (off, stock endpoint, ~/.axor/telemetry_queue.jsonl)
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import tomllib
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
_CONFIG_PATH = Path("~/.axor/config.toml").expanduser()
|
|
18
|
+
_DEFAULT_QUEUE = "~/.axor/telemetry_queue.jsonl"
|
|
19
|
+
_DEFAULT_ENDPOINT = "https://telemetry.useaxor.net/v1/records"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TelemetryMode(str, Enum):
|
|
23
|
+
OFF = "off"
|
|
24
|
+
LOCAL = "local"
|
|
25
|
+
REMOTE = "remote"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class TelemetryConfig:
|
|
30
|
+
mode: TelemetryMode = TelemetryMode.OFF
|
|
31
|
+
endpoint: str = _DEFAULT_ENDPOINT
|
|
32
|
+
queue_path: str = _DEFAULT_QUEUE
|
|
33
|
+
fingerprint_kind: str = "minhash_v1"
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def enabled(self) -> bool:
|
|
37
|
+
return self.mode is not TelemetryMode.OFF
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def ships_remote(self) -> bool:
|
|
41
|
+
return self.mode is TelemetryMode.REMOTE
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def load(cls, config_path: Path | None = None) -> "TelemetryConfig":
|
|
45
|
+
"""Resolve config from env + TOML file. Returns defaults when nothing is set."""
|
|
46
|
+
path = config_path or _CONFIG_PATH
|
|
47
|
+
data: dict = {}
|
|
48
|
+
if path.is_file():
|
|
49
|
+
try:
|
|
50
|
+
with path.open("rb") as fh:
|
|
51
|
+
data = tomllib.load(fh).get("telemetry", {}) or {}
|
|
52
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
53
|
+
data = {}
|
|
54
|
+
|
|
55
|
+
env_mode = os.environ.get("AXOR_TELEMETRY")
|
|
56
|
+
mode_raw = env_mode or data.get("mode") or "off"
|
|
57
|
+
try:
|
|
58
|
+
mode = TelemetryMode(mode_raw.lower())
|
|
59
|
+
except ValueError:
|
|
60
|
+
mode = TelemetryMode.OFF
|
|
61
|
+
|
|
62
|
+
endpoint = os.environ.get("AXOR_TELEMETRY_ENDPOINT") or data.get("endpoint") or _DEFAULT_ENDPOINT
|
|
63
|
+
queue_path = os.environ.get("AXOR_TELEMETRY_QUEUE") or data.get("queue_path") or _DEFAULT_QUEUE
|
|
64
|
+
fingerprint_kind = data.get("fingerprint_kind") or "minhash_v1"
|
|
65
|
+
|
|
66
|
+
return cls(
|
|
67
|
+
mode=mode,
|
|
68
|
+
endpoint=endpoint,
|
|
69
|
+
queue_path=queue_path,
|
|
70
|
+
fingerprint_kind=fingerprint_kind,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def write(self, config_path: Path | None = None) -> Path:
|
|
74
|
+
"""
|
|
75
|
+
Persist [telemetry] section to ~/.axor/config.toml, preserving other
|
|
76
|
+
sections already present. Atomic: writes to tmp file then renames.
|
|
77
|
+
"""
|
|
78
|
+
path = config_path or _CONFIG_PATH
|
|
79
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
|
|
81
|
+
existing = ""
|
|
82
|
+
if path.is_file():
|
|
83
|
+
try:
|
|
84
|
+
existing = path.read_text(encoding="utf-8")
|
|
85
|
+
except OSError:
|
|
86
|
+
existing = ""
|
|
87
|
+
|
|
88
|
+
section = self._render_section()
|
|
89
|
+
new_text = _replace_section(existing, "telemetry", section)
|
|
90
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
91
|
+
tmp.write_text(new_text, encoding="utf-8")
|
|
92
|
+
os.replace(tmp, path)
|
|
93
|
+
try:
|
|
94
|
+
path.chmod(0o600)
|
|
95
|
+
except OSError:
|
|
96
|
+
pass
|
|
97
|
+
return path
|
|
98
|
+
|
|
99
|
+
def _render_section(self) -> str:
|
|
100
|
+
return (
|
|
101
|
+
"[telemetry]\n"
|
|
102
|
+
f'mode = "{self.mode.value}"\n'
|
|
103
|
+
f'endpoint = "{_escape_toml(self.endpoint)}"\n'
|
|
104
|
+
f'queue_path = "{_escape_toml(self.queue_path)}"\n'
|
|
105
|
+
f'fingerprint_kind = "{_escape_toml(self.fingerprint_kind)}"\n'
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _escape_toml(s: str) -> str:
|
|
110
|
+
return s.replace("\\", "\\\\").replace('"', '\\"')
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _replace_section(text: str, section: str, new_block: str) -> str:
|
|
114
|
+
"""
|
|
115
|
+
Rewrite a single `[section]` block in a TOML document. If missing, append.
|
|
116
|
+
Preserves all other sections verbatim.
|
|
117
|
+
"""
|
|
118
|
+
lines = text.splitlines(keepends=True)
|
|
119
|
+
out: list[str] = []
|
|
120
|
+
i = 0
|
|
121
|
+
replaced = False
|
|
122
|
+
header = f"[{section}]"
|
|
123
|
+
while i < len(lines):
|
|
124
|
+
line = lines[i]
|
|
125
|
+
stripped = line.strip()
|
|
126
|
+
if stripped == header:
|
|
127
|
+
# skip until next header or EOF
|
|
128
|
+
j = i + 1
|
|
129
|
+
while j < len(lines) and not lines[j].lstrip().startswith("["):
|
|
130
|
+
j += 1
|
|
131
|
+
out.append(new_block)
|
|
132
|
+
if j < len(lines):
|
|
133
|
+
out.append("\n")
|
|
134
|
+
i = j
|
|
135
|
+
replaced = True
|
|
136
|
+
continue
|
|
137
|
+
out.append(line)
|
|
138
|
+
i += 1
|
|
139
|
+
if not replaced:
|
|
140
|
+
if out and not out[-1].endswith("\n"):
|
|
141
|
+
out.append("\n")
|
|
142
|
+
if out:
|
|
143
|
+
out.append("\n")
|
|
144
|
+
out.append(new_block)
|
|
145
|
+
return "".join(out)
|