deepctl-telemetry 0.0.7__tar.gz → 0.0.8__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.
- {deepctl_telemetry-0.0.7 → deepctl_telemetry-0.0.8}/PKG-INFO +6 -6
- {deepctl_telemetry-0.0.7 → deepctl_telemetry-0.0.8}/README.md +2 -2
- {deepctl_telemetry-0.0.7 → deepctl_telemetry-0.0.8}/pyproject.toml +4 -4
- deepctl_telemetry-0.0.8/src/deepctl_telemetry/__init__.py +15 -0
- deepctl_telemetry-0.0.8/src/deepctl_telemetry/client.py +607 -0
- deepctl_telemetry-0.0.8/src/deepctl_telemetry/notice.py +106 -0
- {deepctl_telemetry-0.0.7 → deepctl_telemetry-0.0.8}/src/deepctl_telemetry.egg-info/PKG-INFO +6 -6
- deepctl_telemetry-0.0.8/src/deepctl_telemetry.egg-info/requires.txt +4 -0
- deepctl_telemetry-0.0.7/src/deepctl_telemetry/__init__.py +0 -13
- deepctl_telemetry-0.0.7/src/deepctl_telemetry/client.py +0 -179
- deepctl_telemetry-0.0.7/src/deepctl_telemetry/notice.py +0 -51
- deepctl_telemetry-0.0.7/src/deepctl_telemetry.egg-info/requires.txt +0 -4
- {deepctl_telemetry-0.0.7 → deepctl_telemetry-0.0.8}/setup.cfg +0 -0
- {deepctl_telemetry-0.0.7 → deepctl_telemetry-0.0.8}/src/deepctl_telemetry.egg-info/SOURCES.txt +0 -0
- {deepctl_telemetry-0.0.7 → deepctl_telemetry-0.0.8}/src/deepctl_telemetry.egg-info/dependency_links.txt +0 -0
- {deepctl_telemetry-0.0.7 → deepctl_telemetry-0.0.8}/src/deepctl_telemetry.egg-info/top_level.txt +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deepctl-telemetry
|
|
3
|
-
Version: 0.0.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 0.0.8
|
|
4
|
+
Summary: Error reporting for deepctl; this version sends no telemetry
|
|
5
5
|
Author-email: Deepgram <devrel@deepgram.com>
|
|
6
6
|
Maintainer-email: Deepgram <devrel@deepgram.com>
|
|
7
7
|
License-Expression: MIT
|
|
@@ -14,8 +14,8 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.12
|
|
15
15
|
Requires-Python: >=3.10
|
|
16
16
|
Description-Content-Type: text/markdown
|
|
17
|
-
Requires-Dist: deepctl-core>=0.
|
|
18
|
-
Requires-Dist: sentry-sdk
|
|
17
|
+
Requires-Dist: deepctl-core>=0.3.0
|
|
18
|
+
Requires-Dist: sentry-sdk<3,>=2.35.0
|
|
19
19
|
Requires-Dist: click>=8.0.0
|
|
20
20
|
Requires-Dist: rich>=13.0.0
|
|
21
21
|
|
|
@@ -23,7 +23,7 @@ Requires-Dist: rich>=13.0.0
|
|
|
23
23
|
|
|
24
24
|
> Part of [deepctl](https://github.com/deepgram/cli) — Official Deepgram CLI
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
Error reporting for deepctl; this version sends no telemetry
|
|
27
27
|
|
|
28
28
|
This package provides internal APIs for deepctl and its command packages. It is not intended for direct use.
|
|
29
29
|
|
|
@@ -50,7 +50,7 @@ pipx run deepctl --help
|
|
|
50
50
|
|
|
51
51
|
## Dependencies
|
|
52
52
|
|
|
53
|
-
- `sentry-sdk>=2.
|
|
53
|
+
- `sentry-sdk>=2.35.0,<3`
|
|
54
54
|
- `click>=8.0.0`
|
|
55
55
|
- `rich>=13.0.0`
|
|
56
56
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> Part of [deepctl](https://github.com/deepgram/cli) — Official Deepgram CLI
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Error reporting for deepctl; this version sends no telemetry
|
|
6
6
|
|
|
7
7
|
This package provides internal APIs for deepctl and its command packages. It is not intended for direct use.
|
|
8
8
|
|
|
@@ -29,7 +29,7 @@ pipx run deepctl --help
|
|
|
29
29
|
|
|
30
30
|
## Dependencies
|
|
31
31
|
|
|
32
|
-
- `sentry-sdk>=2.
|
|
32
|
+
- `sentry-sdk>=2.35.0,<3`
|
|
33
33
|
- `click>=8.0.0`
|
|
34
34
|
- `rich>=13.0.0`
|
|
35
35
|
|
|
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "deepctl-telemetry"
|
|
7
|
-
version = "0.0.
|
|
8
|
-
description = "
|
|
7
|
+
version = "0.0.8" # x-release-please-version
|
|
8
|
+
description = "Error reporting for deepctl; this version sends no telemetry"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
11
11
|
authors = [{ name = "Deepgram", email = "devrel@deepgram.com" }]
|
|
@@ -21,8 +21,8 @@ classifiers = [
|
|
|
21
21
|
keywords = ["deepgram", "cli", "telemetry", "sentry"]
|
|
22
22
|
requires-python = ">=3.10"
|
|
23
23
|
dependencies = [
|
|
24
|
-
"deepctl-core>=0.
|
|
25
|
-
"sentry-sdk>=2.
|
|
24
|
+
"deepctl-core>=0.3.0",
|
|
25
|
+
"sentry-sdk>=2.35.0,<3",
|
|
26
26
|
"click>=8.0.0",
|
|
27
27
|
"rich>=13.0.0",
|
|
28
28
|
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Error reporting for deepctl. This version sends no telemetry."""
|
|
2
|
+
|
|
3
|
+
from .client import disable_for_run, init_telemetry, is_enabled, report_exception
|
|
4
|
+
from .notice import install_help_notice, render_notice
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"disable_for_run",
|
|
8
|
+
"init_telemetry",
|
|
9
|
+
"install_help_notice",
|
|
10
|
+
"is_enabled",
|
|
11
|
+
"render_notice",
|
|
12
|
+
"report_exception",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
__version__ = "0.0.8" # x-release-please-version
|
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
"""Sentry-backed telemetry client: unexpected errors only, allowlisted fields.
|
|
2
|
+
|
|
3
|
+
This version has no built-in destination, so it sends no telemetry. A
|
|
4
|
+
report can go only to a DEEPCTL_TELEMETRY_DSN override on a loopback
|
|
5
|
+
address, which exists for tests and local sinks. Without one, telemetry is
|
|
6
|
+
off: no hooks are installed and the SDK is never imported.
|
|
7
|
+
|
|
8
|
+
The SDK is never imported on the success path. ``init_telemetry`` only
|
|
9
|
+
records the enabled decision and installs stdlib exception hooks;
|
|
10
|
+
``report_exception`` imports and initializes the SDK when an error actually
|
|
11
|
+
reaches it, sends one event, and flushes within a fixed time budget.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import platform
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import TYPE_CHECKING, Any
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from types import TracebackType
|
|
27
|
+
|
|
28
|
+
from deepctl_core import Config
|
|
29
|
+
from sentry_sdk.types import Event, Hint
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Build switch. Set to False to ship a build in which telemetry is inert:
|
|
33
|
+
# no hooks are installed, the SDK is never imported, DEEPCTL_TELEMETRY_DSN is
|
|
34
|
+
# ignored, and `--help` says this version of dg sends no telemetry.
|
|
35
|
+
TELEMETRY_BUILD_ENABLED = True
|
|
36
|
+
|
|
37
|
+
# There is no built-in destination: _dsn() returns a DEEPCTL_TELEMETRY_DSN
|
|
38
|
+
# override on a loopback address, or None, and with None nothing is sent.
|
|
39
|
+
|
|
40
|
+
DSN_ENV_VAR = "DEEPCTL_TELEMETRY_DSN"
|
|
41
|
+
DISABLE_ENV_VAR = "DEEPCTL_TELEMETRY_DISABLED"
|
|
42
|
+
|
|
43
|
+
# Values of DEEPCTL_TELEMETRY_DISABLED that leave telemetry on. Any other
|
|
44
|
+
# non-empty value turns it off.
|
|
45
|
+
_ENV_KEEPS_ON = frozenset({"0", "false", "no", "off"})
|
|
46
|
+
|
|
47
|
+
# Flush timeout once the SDK is initialized, and the cap on the whole report
|
|
48
|
+
# (SDK import and init included). The flush always gets at least the floor.
|
|
49
|
+
FLUSH_TIMEOUT_SECONDS = 0.5
|
|
50
|
+
REPORT_BUDGET_SECONDS = 0.6
|
|
51
|
+
FLUSH_FLOOR_SECONDS = 0.05
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _fresh_state() -> dict[str, Any]:
|
|
55
|
+
"""Per-run state, stdlib types only.
|
|
56
|
+
|
|
57
|
+
`opt_out` names the switch that turned telemetry off ("build", "dsn"
|
|
58
|
+
when there is no destination, "flag", "env" or "config"); `config_path`
|
|
59
|
+
is the user config file the decision was read from; `reported` is set
|
|
60
|
+
once a report starts, `sent` once an event has been handed to the
|
|
61
|
+
transport.
|
|
62
|
+
"""
|
|
63
|
+
return {
|
|
64
|
+
"enabled": False,
|
|
65
|
+
"opt_out": None,
|
|
66
|
+
"config_path": None,
|
|
67
|
+
"hooks_installed": False,
|
|
68
|
+
"reported": False,
|
|
69
|
+
"sent": False,
|
|
70
|
+
"disabled_for_run": False,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
_state: dict[str, Any] = _fresh_state()
|
|
75
|
+
_report_lock = threading.Lock()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def disable_for_run() -> None:
|
|
79
|
+
"""Turn telemetry off for this process only (`dg --no-telemetry`)."""
|
|
80
|
+
_state["disabled_for_run"] = True
|
|
81
|
+
_state["enabled"] = False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _env_opts_out() -> bool:
|
|
85
|
+
value = os.environ.get(DISABLE_ENV_VAR, "").strip().lower()
|
|
86
|
+
return bool(value) and value not in _ENV_KEEPS_ON
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# Keys whose repetition in a config file makes the telemetry setting
|
|
90
|
+
# ambiguous; a file that repeats one counts as opted out.
|
|
91
|
+
_TELEMETRY_KEYS = frozenset({"telemetry", "enabled", "telemetry.enabled"})
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _default_config_path() -> Path:
|
|
95
|
+
"""The default user config file (the one Config uses without --config)."""
|
|
96
|
+
import platformdirs
|
|
97
|
+
|
|
98
|
+
return Path(platformdirs.user_config_dir("deepctl", "deepgram")) / "config.yaml"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
_REFUSED = object()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _file_opts_out(config_file: Path, text: object = None) -> bool:
|
|
105
|
+
"""Whether one user config file turns telemetry off.
|
|
106
|
+
|
|
107
|
+
`text` is the file's content as Config read it, or _REFUSED when Config
|
|
108
|
+
didn't use the file. Without it, the file is read here through core's
|
|
109
|
+
config reader (read_user_config_text), with the same limits and
|
|
110
|
+
refusals as a config load, and never at all when Config refused it.
|
|
111
|
+
Telemetry never parses config YAML itself: core's load_config_text
|
|
112
|
+
does, with every load limit.
|
|
113
|
+
|
|
114
|
+
Telemetry stays on when the file is absent or empty, or has no
|
|
115
|
+
`telemetry` setting, or sets `enabled` to the boolean `true`. A file
|
|
116
|
+
that a load would refuse (not a regular file, too large, a bomb, a
|
|
117
|
+
parse error, an in-place rewrite that didn't finish, untrusted in an AI
|
|
118
|
+
agent session) turns it off, as does one that repeats a telemetry key,
|
|
119
|
+
has a `telemetry` section that isn't a mapping, or holds any other
|
|
120
|
+
`enabled` value. A top-level `telemetry.enabled` key counts too.
|
|
121
|
+
"""
|
|
122
|
+
if text is _REFUSED:
|
|
123
|
+
return True
|
|
124
|
+
try:
|
|
125
|
+
from deepctl_core.config import load_config_text, read_user_config_text
|
|
126
|
+
except ImportError: # an older deepctl-core: no capped reader to use
|
|
127
|
+
return True
|
|
128
|
+
if not isinstance(text, str):
|
|
129
|
+
try:
|
|
130
|
+
text = read_user_config_text(config_file)
|
|
131
|
+
except FileNotFoundError:
|
|
132
|
+
return False
|
|
133
|
+
except Exception:
|
|
134
|
+
return True
|
|
135
|
+
if text is None:
|
|
136
|
+
return True
|
|
137
|
+
try:
|
|
138
|
+
data, repeated = load_config_text(text, _TELEMETRY_KEYS)
|
|
139
|
+
except Exception:
|
|
140
|
+
return True
|
|
141
|
+
if repeated:
|
|
142
|
+
return True
|
|
143
|
+
if data is None:
|
|
144
|
+
return False
|
|
145
|
+
if not isinstance(data, dict):
|
|
146
|
+
return True
|
|
147
|
+
if "telemetry.enabled" in data and data["telemetry.enabled"] is not True:
|
|
148
|
+
return True
|
|
149
|
+
if "telemetry" not in data:
|
|
150
|
+
return False
|
|
151
|
+
section = data["telemetry"]
|
|
152
|
+
if not isinstance(section, dict):
|
|
153
|
+
return True
|
|
154
|
+
if "enabled" not in section:
|
|
155
|
+
return False
|
|
156
|
+
return section["enabled"] is not True
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _user_config_opts_out(config: Config) -> bool:
|
|
160
|
+
"""Read `telemetry.enabled` from the user's own config files only.
|
|
161
|
+
|
|
162
|
+
Both the default config file and, with `--config`, the named file count;
|
|
163
|
+
telemetry is off if either turns it off. The file Config loaded is
|
|
164
|
+
parsed from the text Config kept, and counts as opted out when Config
|
|
165
|
+
refused it. The merged Config is not used, because it also holds the
|
|
166
|
+
project's `deepgram.yaml`.
|
|
167
|
+
"""
|
|
168
|
+
files: dict[str, object] = {}
|
|
169
|
+
explicit = getattr(config, "config_path", None)
|
|
170
|
+
if explicit is not None:
|
|
171
|
+
text = getattr(config, "user_config_text", None)
|
|
172
|
+
if getattr(config, "user_config_refused", False) is True:
|
|
173
|
+
files[str(explicit)] = _REFUSED
|
|
174
|
+
else:
|
|
175
|
+
files[str(explicit)] = text if isinstance(text, str) else None
|
|
176
|
+
try:
|
|
177
|
+
files.setdefault(str(_default_config_path()), None)
|
|
178
|
+
except Exception:
|
|
179
|
+
return True
|
|
180
|
+
return any(_file_opts_out(Path(path), text) for path, text in files.items())
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _opt_out_source(config: Config) -> str | None:
|
|
184
|
+
if not TELEMETRY_BUILD_ENABLED:
|
|
185
|
+
return "build"
|
|
186
|
+
if _dsn() is None:
|
|
187
|
+
return "dsn"
|
|
188
|
+
if _state["disabled_for_run"]:
|
|
189
|
+
return "flag"
|
|
190
|
+
if _env_opts_out():
|
|
191
|
+
return "env"
|
|
192
|
+
if _user_config_opts_out(config):
|
|
193
|
+
return "config"
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def is_enabled(config: Config) -> bool:
|
|
198
|
+
"""Whether telemetry may report an error for this invocation.
|
|
199
|
+
|
|
200
|
+
Resolution order: the build switch, then whether there is a
|
|
201
|
+
destination (a loopback DEEPCTL_TELEMETRY_DSN; without one telemetry is
|
|
202
|
+
off), then `--no-telemetry` for this run, then the env override
|
|
203
|
+
(DEEPCTL_TELEMETRY_DISABLED), then `telemetry.enabled` in the user's
|
|
204
|
+
config file (default on). The project's `deepgram.yaml` is never read.
|
|
205
|
+
"""
|
|
206
|
+
return _opt_out_source(config) is None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def init_telemetry(config: Config) -> bool:
|
|
210
|
+
"""Record whether telemetry is on and install the error hooks.
|
|
211
|
+
|
|
212
|
+
Called after option parsing with the resolved Config, so `--config` and
|
|
213
|
+
`--no-telemetry` are honored. Does not import the SDK. Safe to call
|
|
214
|
+
more than once; the latest call wins. Returns whether telemetry is on.
|
|
215
|
+
"""
|
|
216
|
+
opt_out = _opt_out_source(config)
|
|
217
|
+
_state["opt_out"] = opt_out
|
|
218
|
+
_state["enabled"] = opt_out is None
|
|
219
|
+
path = getattr(config, "config_path", None)
|
|
220
|
+
_state["config_path"] = str(path) if path is not None else None
|
|
221
|
+
if _state["enabled"]:
|
|
222
|
+
_install_hooks()
|
|
223
|
+
return bool(_state["enabled"])
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _install_hooks() -> None:
|
|
227
|
+
"""Chain stdlib hooks for exceptions that escape `main()` or a thread."""
|
|
228
|
+
if _state["hooks_installed"]:
|
|
229
|
+
return
|
|
230
|
+
previous_excepthook = sys.excepthook
|
|
231
|
+
previous_thread_hook = threading.excepthook
|
|
232
|
+
|
|
233
|
+
def excepthook(
|
|
234
|
+
exc_type: type[BaseException],
|
|
235
|
+
exc: BaseException,
|
|
236
|
+
tb: TracebackType | None,
|
|
237
|
+
) -> None:
|
|
238
|
+
report_exception(exc)
|
|
239
|
+
previous_excepthook(exc_type, exc, tb)
|
|
240
|
+
|
|
241
|
+
def thread_excepthook(args: threading.ExceptHookArgs) -> None:
|
|
242
|
+
if args.exc_value is not None:
|
|
243
|
+
report_exception(args.exc_value)
|
|
244
|
+
previous_thread_hook(args)
|
|
245
|
+
|
|
246
|
+
sys.excepthook = excepthook
|
|
247
|
+
threading.excepthook = thread_excepthook
|
|
248
|
+
_state["hooks_installed"] = True
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _unexpected_error_types() -> tuple[type[BaseException], ...]:
|
|
252
|
+
try:
|
|
253
|
+
from deepctl_core.base_command import UnexpectedCommandError
|
|
254
|
+
except ImportError:
|
|
255
|
+
return ()
|
|
256
|
+
return (UnexpectedCommandError,)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _reportable(exc: BaseException | None) -> BaseException | None:
|
|
260
|
+
"""The exception worth reporting, or None for user errors and exits.
|
|
261
|
+
|
|
262
|
+
Any ClickException is an expected outcome (a usage error, or a command
|
|
263
|
+
reporting a problem to the user), except deepctl-core's
|
|
264
|
+
UnexpectedCommandError: BaseCommand raises it `from` an error it didn't
|
|
265
|
+
expect, and that cause is what gets reported.
|
|
266
|
+
"""
|
|
267
|
+
import click
|
|
268
|
+
|
|
269
|
+
if isinstance(exc, _unexpected_error_types()):
|
|
270
|
+
exc = exc.__cause__
|
|
271
|
+
if exc is None or isinstance(exc, click.ClickException):
|
|
272
|
+
return None
|
|
273
|
+
if isinstance(exc, (click.exceptions.Exit, click.Abort)):
|
|
274
|
+
return None
|
|
275
|
+
return exc if isinstance(exc, Exception) else None
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _dsn() -> str | None:
|
|
279
|
+
"""A DEEPCTL_TELEMETRY_DSN override on loopback, or None.
|
|
280
|
+
|
|
281
|
+
There is no built-in DSN, so without an override there is nowhere to
|
|
282
|
+
send a report. The override exists for tests and local sinks, so a
|
|
283
|
+
non-loopback value is ignored rather than allowed to redirect reports.
|
|
284
|
+
"""
|
|
285
|
+
override = os.environ.get(DSN_ENV_VAR, "").strip()
|
|
286
|
+
if not override:
|
|
287
|
+
return None
|
|
288
|
+
try:
|
|
289
|
+
import ipaddress
|
|
290
|
+
from urllib.parse import urlsplit
|
|
291
|
+
|
|
292
|
+
host = urlsplit(override).hostname or ""
|
|
293
|
+
if host == "localhost" or ipaddress.ip_address(host).is_loopback:
|
|
294
|
+
return override
|
|
295
|
+
except ValueError:
|
|
296
|
+
pass
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def report_exception(exc: BaseException | None) -> bool:
|
|
301
|
+
"""Send one error report for `exc` if telemetry is on.
|
|
302
|
+
|
|
303
|
+
Does nothing, and never imports the SDK, without a loopback
|
|
304
|
+
DEEPCTL_TELEMETRY_DSN. Imports and initializes the SDK only here. At
|
|
305
|
+
most one report per process. The flush waits at most
|
|
306
|
+
FLUSH_TIMEOUT_SECONDS after init, and the whole call is held to
|
|
307
|
+
REPORT_BUDGET_SECONDS (but the flush always gets FLUSH_FLOOR_SECONDS).
|
|
308
|
+
Never raises an Exception; a KeyboardInterrupt during the flush
|
|
309
|
+
propagates to the caller. Returns whether an event was handed to the
|
|
310
|
+
SDK.
|
|
311
|
+
"""
|
|
312
|
+
started = time.monotonic()
|
|
313
|
+
with _report_lock:
|
|
314
|
+
if not TELEMETRY_BUILD_ENABLED or not _state["enabled"] or _state["reported"]:
|
|
315
|
+
return False
|
|
316
|
+
dsn = _dsn()
|
|
317
|
+
if dsn is None:
|
|
318
|
+
return False
|
|
319
|
+
target = _reportable(exc)
|
|
320
|
+
if target is None:
|
|
321
|
+
return False
|
|
322
|
+
_state["reported"] = True
|
|
323
|
+
|
|
324
|
+
try:
|
|
325
|
+
import sentry_sdk
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
cli_version = _read_cli_version()
|
|
329
|
+
except Exception:
|
|
330
|
+
cli_version = "unknown"
|
|
331
|
+
|
|
332
|
+
sentry_sdk.init(**_sdk_options(dsn, cli_version))
|
|
333
|
+
sentry_sdk.capture_exception(target)
|
|
334
|
+
left = REPORT_BUDGET_SECONDS - (time.monotonic() - started)
|
|
335
|
+
timeout = max(FLUSH_FLOOR_SECONDS, min(FLUSH_TIMEOUT_SECONDS, left))
|
|
336
|
+
sentry_sdk.flush(timeout=timeout)
|
|
337
|
+
return True
|
|
338
|
+
except Exception:
|
|
339
|
+
return False
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _read_cli_version() -> str:
|
|
343
|
+
import importlib.metadata
|
|
344
|
+
|
|
345
|
+
return importlib.metadata.version("deepctl")
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _sdk_options(dsn: str, cli_version: str) -> dict[str, Any]:
|
|
349
|
+
"""Explicit SDK settings: one error event per failure and nothing else.
|
|
350
|
+
|
|
351
|
+
No integrations at all (no argv, modules, logging, breadcrumbs, and none
|
|
352
|
+
of the SDK's own excepthook or threading capture: the stdlib hooks in
|
|
353
|
+
`_install_hooks` are the only capture path, so the once-per-process
|
|
354
|
+
rule holds). No traces, profiles, sessions or logs, and no locals,
|
|
355
|
+
source lines, stack snapshots or hostname. No proxy: an empty
|
|
356
|
+
`http_proxy` and `https_proxy` stop the SDK from falling back to a proxy
|
|
357
|
+
from the environment or the system settings, so a report to a loopback
|
|
358
|
+
DSN stays on this machine. `before_send` and `before_send_transaction`
|
|
359
|
+
then rebuild whatever is left from the allowlist in `_rebuild_event`.
|
|
360
|
+
"""
|
|
361
|
+
return {
|
|
362
|
+
"dsn": dsn,
|
|
363
|
+
"release": f"deepctl@{cli_version}",
|
|
364
|
+
"environment": "production",
|
|
365
|
+
"default_integrations": False,
|
|
366
|
+
"auto_enabling_integrations": False,
|
|
367
|
+
"integrations": [],
|
|
368
|
+
"traces_sample_rate": 0.0,
|
|
369
|
+
"profiles_sample_rate": 0.0,
|
|
370
|
+
"enable_logs": False,
|
|
371
|
+
"auto_session_tracking": False,
|
|
372
|
+
"attach_stacktrace": False,
|
|
373
|
+
"include_local_variables": False,
|
|
374
|
+
"include_source_context": False,
|
|
375
|
+
"max_breadcrumbs": 0,
|
|
376
|
+
"send_default_pii": False,
|
|
377
|
+
"send_client_reports": False,
|
|
378
|
+
"server_name": "",
|
|
379
|
+
"spotlight": False,
|
|
380
|
+
"http_proxy": "",
|
|
381
|
+
"https_proxy": "",
|
|
382
|
+
"before_send": _before_send,
|
|
383
|
+
"before_send_transaction": _rebuild_event,
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _before_send(event: Event, hint: Hint) -> Event | None:
|
|
388
|
+
"""Rebuild the event, and let only the first one in the process through."""
|
|
389
|
+
with _report_lock:
|
|
390
|
+
if _state["sent"]:
|
|
391
|
+
return None
|
|
392
|
+
rebuilt = _rebuild_event(event, hint)
|
|
393
|
+
if rebuilt is not None:
|
|
394
|
+
_state["sent"] = True
|
|
395
|
+
return rebuilt
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _is_broken_pipe(event: Event) -> bool:
|
|
399
|
+
"""Identify events caused by writing to a closed/broken output stream.
|
|
400
|
+
|
|
401
|
+
When `dg mcp` runs as an MCP server, the host can close stdio while the
|
|
402
|
+
CLI is still finishing up. Any residual write (an advisory notification, a
|
|
403
|
+
console error message) then raises ``BrokenPipeError`` — or rich's
|
|
404
|
+
``ValueError: I/O operation on closed file`` when the underlying stream is
|
|
405
|
+
already closed. Neither is an actionable CLI bug, so drop them rather than
|
|
406
|
+
page the DX team.
|
|
407
|
+
"""
|
|
408
|
+
for exc in (event.get("exception") or {}).get("values") or []:
|
|
409
|
+
exc_type = exc.get("type") or ""
|
|
410
|
+
if exc_type == "BrokenPipeError":
|
|
411
|
+
return True
|
|
412
|
+
if exc_type == "ValueError" and "closed file" in (exc.get("value") or ""):
|
|
413
|
+
return True
|
|
414
|
+
return False
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
# Top-level packages whose module and exception-type names may be sent: dg's
|
|
418
|
+
# own packages, the libraries it calls, and the standard library. Any other
|
|
419
|
+
# module (a plugin, even one named deepctl_*, a user script, `__main__`) is
|
|
420
|
+
# sent as OTHER_NAME. Keep _FIRST_PARTY in step with packages/*/src.
|
|
421
|
+
_FIRST_PARTY = frozenset(
|
|
422
|
+
{
|
|
423
|
+
"deepctl",
|
|
424
|
+
"deepctl_cmd_api",
|
|
425
|
+
"deepctl_cmd_billing",
|
|
426
|
+
"deepctl_cmd_completion",
|
|
427
|
+
"deepctl_cmd_debug",
|
|
428
|
+
"deepctl_cmd_debug_audio",
|
|
429
|
+
"deepctl_cmd_debug_browser",
|
|
430
|
+
"deepctl_cmd_debug_network",
|
|
431
|
+
"deepctl_cmd_debug_probe",
|
|
432
|
+
"deepctl_cmd_debug_toolkit",
|
|
433
|
+
"deepctl_cmd_ffprobe",
|
|
434
|
+
"deepctl_cmd_init",
|
|
435
|
+
"deepctl_cmd_keys",
|
|
436
|
+
"deepctl_cmd_listen",
|
|
437
|
+
"deepctl_cmd_login",
|
|
438
|
+
"deepctl_cmd_mcp",
|
|
439
|
+
"deepctl_cmd_members",
|
|
440
|
+
"deepctl_cmd_models",
|
|
441
|
+
"deepctl_cmd_plugin",
|
|
442
|
+
"deepctl_cmd_projects",
|
|
443
|
+
"deepctl_cmd_read",
|
|
444
|
+
"deepctl_cmd_requests",
|
|
445
|
+
"deepctl_cmd_skills",
|
|
446
|
+
"deepctl_cmd_speak",
|
|
447
|
+
"deepctl_cmd_transcribe",
|
|
448
|
+
"deepctl_cmd_update",
|
|
449
|
+
"deepctl_cmd_usage",
|
|
450
|
+
"deepctl_core",
|
|
451
|
+
"deepctl_shared_utils",
|
|
452
|
+
"deepctl_telemetry",
|
|
453
|
+
}
|
|
454
|
+
)
|
|
455
|
+
_KNOWN_PACKAGES = frozenset(
|
|
456
|
+
{
|
|
457
|
+
"anyio",
|
|
458
|
+
"certifi",
|
|
459
|
+
"click",
|
|
460
|
+
"deepgram",
|
|
461
|
+
"deepgram_mcp",
|
|
462
|
+
"h11",
|
|
463
|
+
"httpcore",
|
|
464
|
+
"httpx",
|
|
465
|
+
"keyring",
|
|
466
|
+
"mcp",
|
|
467
|
+
"platformdirs",
|
|
468
|
+
"pydantic",
|
|
469
|
+
"pydantic_core",
|
|
470
|
+
"requests",
|
|
471
|
+
"rich",
|
|
472
|
+
"sentry_sdk",
|
|
473
|
+
"typer",
|
|
474
|
+
"urllib3",
|
|
475
|
+
"websockets",
|
|
476
|
+
"yaml",
|
|
477
|
+
}
|
|
478
|
+
)
|
|
479
|
+
OTHER_NAME = "<other>"
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _known_module(module: str) -> bool:
|
|
483
|
+
top = module.split(".", 1)[0]
|
|
484
|
+
return (
|
|
485
|
+
top in _FIRST_PARTY
|
|
486
|
+
or top in _KNOWN_PACKAGES
|
|
487
|
+
or top in getattr(sys, "stdlib_module_names", ())
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
# Identifier-like tokens only. A value that doesn't match is dropped, never
|
|
492
|
+
# escaped or truncated, so free text can't reach the payload.
|
|
493
|
+
_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.]{0,127}$")
|
|
494
|
+
_FUNCTION_RE = re.compile(r"^(<[a-z]+>|[A-Za-z_][A-Za-z0-9_.<>]{0,127})$")
|
|
495
|
+
_VERSION_RE = re.compile(r"^[A-Za-z0-9_.+-]{1,64}$")
|
|
496
|
+
_COMMAND_RE = re.compile(r"^[A-Za-z0-9 _-]{1,200}$")
|
|
497
|
+
_FLAGS_RE = re.compile(r"^[A-Za-z0-9,_-]{0,1000}$")
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _match(pattern: re.Pattern[str], value: object) -> str | None:
|
|
501
|
+
return value if isinstance(value, str) and pattern.match(value) else None
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _allowed_tags() -> dict[str, str]:
|
|
505
|
+
"""Run-level fields: command path, flag names, version, OS, arch, Python."""
|
|
506
|
+
try:
|
|
507
|
+
from deepctl_core.telemetry_context import command_context
|
|
508
|
+
|
|
509
|
+
context = command_context()
|
|
510
|
+
except ImportError:
|
|
511
|
+
# An older deepctl-core without the context: report without cmd.*.
|
|
512
|
+
context = {}
|
|
513
|
+
candidates = {
|
|
514
|
+
"cmd.path": _match(_COMMAND_RE, context.get("command")),
|
|
515
|
+
"cmd.flags": _match(_FLAGS_RE, context.get("flags")),
|
|
516
|
+
"cli.os": _match(_VERSION_RE, platform.system()),
|
|
517
|
+
"cli.arch": _match(_VERSION_RE, platform.machine()),
|
|
518
|
+
"cli.python": _match(_VERSION_RE, platform.python_version()),
|
|
519
|
+
}
|
|
520
|
+
return {key: value for key, value in candidates.items() if value is not None}
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _allowed_exception(exc: dict[str, Any]) -> dict[str, Any] | None:
|
|
524
|
+
"""Exception type, mechanism, and module/function/line per frame.
|
|
525
|
+
|
|
526
|
+
Names from modules outside `_known_module` become OTHER_NAME, so a
|
|
527
|
+
plugin's or script's own identifiers are never sent.
|
|
528
|
+
"""
|
|
529
|
+
exc_type = _match(_NAME_RE, exc.get("type"))
|
|
530
|
+
if exc_type is None:
|
|
531
|
+
return None
|
|
532
|
+
type_module = _match(_NAME_RE, exc.get("module")) or "builtins"
|
|
533
|
+
if not _known_module(type_module):
|
|
534
|
+
exc_type = OTHER_NAME
|
|
535
|
+
|
|
536
|
+
frames: list[dict[str, Any]] = []
|
|
537
|
+
for frame in (exc.get("stacktrace") or {}).get("frames") or []:
|
|
538
|
+
kept: dict[str, Any] = {}
|
|
539
|
+
module = _match(_NAME_RE, frame.get("module"))
|
|
540
|
+
function = _match(_FUNCTION_RE, frame.get("function"))
|
|
541
|
+
lineno = frame.get("lineno")
|
|
542
|
+
if module is None or not _known_module(module):
|
|
543
|
+
module = function = OTHER_NAME
|
|
544
|
+
kept["module"] = module
|
|
545
|
+
if function is not None:
|
|
546
|
+
kept["function"] = function
|
|
547
|
+
if isinstance(lineno, int) and not isinstance(lineno, bool):
|
|
548
|
+
kept["lineno"] = lineno
|
|
549
|
+
if kept:
|
|
550
|
+
frames.append(kept)
|
|
551
|
+
|
|
552
|
+
rebuilt: dict[str, Any] = {"type": exc_type}
|
|
553
|
+
mechanism = exc.get("mechanism") or {}
|
|
554
|
+
mechanism_type = _match(_NAME_RE, mechanism.get("type"))
|
|
555
|
+
if mechanism_type is not None:
|
|
556
|
+
rebuilt["mechanism"] = {"type": mechanism_type}
|
|
557
|
+
if isinstance(mechanism.get("handled"), bool):
|
|
558
|
+
rebuilt["mechanism"]["handled"] = mechanism["handled"]
|
|
559
|
+
if frames:
|
|
560
|
+
rebuilt["stacktrace"] = {"frames": frames}
|
|
561
|
+
return rebuilt
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _rebuild_event(event: Event, _hint: Hint) -> Event | None:
|
|
565
|
+
"""Build a new event from allowlisted fields; everything else is dropped.
|
|
566
|
+
|
|
567
|
+
The SDK's event is only read, never forwarded: messages, exception
|
|
568
|
+
values, paths, variables, request data, contexts, extra, modules,
|
|
569
|
+
breadcrumbs, user and server_name never make it into the new event.
|
|
570
|
+
Events without an exception (log messages, transactions) are dropped.
|
|
571
|
+
|
|
572
|
+
Also drops closed-stream events (see ``_is_broken_pipe``).
|
|
573
|
+
"""
|
|
574
|
+
if _is_broken_pipe(event):
|
|
575
|
+
return None
|
|
576
|
+
|
|
577
|
+
values = [
|
|
578
|
+
rebuilt
|
|
579
|
+
for exc in (event.get("exception") or {}).get("values") or []
|
|
580
|
+
if (rebuilt := _allowed_exception(exc)) is not None
|
|
581
|
+
]
|
|
582
|
+
if not values:
|
|
583
|
+
return None
|
|
584
|
+
|
|
585
|
+
rebuilt_event: dict[str, Any] = {
|
|
586
|
+
"platform": "python",
|
|
587
|
+
"level": "error",
|
|
588
|
+
"exception": {"values": values},
|
|
589
|
+
"tags": _allowed_tags(),
|
|
590
|
+
}
|
|
591
|
+
for key, pattern in (
|
|
592
|
+
("event_id", re.compile(r"^[0-9a-f]{32}$")),
|
|
593
|
+
("release", re.compile(r"^deepctl@[A-Za-z0-9_.+-]{1,64}$")),
|
|
594
|
+
("environment", _NAME_RE),
|
|
595
|
+
):
|
|
596
|
+
value = _match(pattern, event.get(key))
|
|
597
|
+
if value is not None:
|
|
598
|
+
rebuilt_event[key] = value
|
|
599
|
+
timestamp = event.get("timestamp")
|
|
600
|
+
if timestamp is not None:
|
|
601
|
+
rebuilt_event["timestamp"] = timestamp
|
|
602
|
+
sdk = event.get("sdk") or {}
|
|
603
|
+
sdk_name = _match(_NAME_RE, sdk.get("name"))
|
|
604
|
+
sdk_version = _match(_VERSION_RE, sdk.get("version"))
|
|
605
|
+
if sdk_name and sdk_version:
|
|
606
|
+
rebuilt_event["sdk"] = {"name": sdk_name, "version": sdk_version}
|
|
607
|
+
return rebuilt_event # type: ignore[return-value]
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Subtle telemetry notice appended to `--help` output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
|
|
14
|
+
from deepctl_core import Config
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Golden text, compared byte for byte with tests/golden/notice_*.txt.
|
|
18
|
+
# NOTICE_ON shows only with a loopback DEEPCTL_TELEMETRY_DSN (tests and local
|
|
19
|
+
# sinks). It names the three opt-outs that exist: the flag for one run, the env
|
|
20
|
+
# var, and the config-file key.
|
|
21
|
+
NOTICE_ON = (
|
|
22
|
+
"Telemetry is on: unexpected errors only, with no flag values, paths or "
|
|
23
|
+
"keys. Turn off: --no-telemetry, DEEPCTL_TELEMETRY_DISABLED=1, or "
|
|
24
|
+
"telemetry.enabled: false in config.yaml."
|
|
25
|
+
)
|
|
26
|
+
NOTICE_OFF = "Telemetry is off."
|
|
27
|
+
# No destination (no loopback DEEPCTL_TELEMETRY_DSN), or the build switch is
|
|
28
|
+
# off: what every user of this version sees.
|
|
29
|
+
NOTICE_NONE = "This version of dg sends no telemetry."
|
|
30
|
+
|
|
31
|
+
_installed = False
|
|
32
|
+
# The latest fallback passed to install_help_notice (one per main() call).
|
|
33
|
+
_fallback: list[Callable[[], Config]] = []
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def render_notice(config: Config) -> str:
|
|
37
|
+
"""Return the one-line notice for the current telemetry state.
|
|
38
|
+
|
|
39
|
+
Dim only on a terminal with NO_COLOR unset; `click.style` doesn't check
|
|
40
|
+
either on its own.
|
|
41
|
+
"""
|
|
42
|
+
from .client import _opt_out_source
|
|
43
|
+
|
|
44
|
+
source = _opt_out_source(config)
|
|
45
|
+
if source is None:
|
|
46
|
+
text = NOTICE_ON
|
|
47
|
+
elif source in ("build", "dsn"):
|
|
48
|
+
text = NOTICE_NONE
|
|
49
|
+
else:
|
|
50
|
+
text = NOTICE_OFF
|
|
51
|
+
if os.environ.get("NO_COLOR") or not _stdout_is_tty():
|
|
52
|
+
return text
|
|
53
|
+
return click.style(text, dim=True)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _stdout_is_tty() -> bool:
|
|
57
|
+
try:
|
|
58
|
+
return sys.stdout.isatty()
|
|
59
|
+
except Exception:
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _config_for_help(
|
|
64
|
+
ctx: click.Context, fallback_config: Callable[[], Config]
|
|
65
|
+
) -> Config:
|
|
66
|
+
"""The Config this run resolved, or the fallback for root-level help.
|
|
67
|
+
|
|
68
|
+
Subcommand help runs after the root callback has built the Config from
|
|
69
|
+
`--config`/`--profile`, so it is on the root context. Root-level
|
|
70
|
+
`--help` fires during option parsing, before that, so it uses the
|
|
71
|
+
fallback.
|
|
72
|
+
"""
|
|
73
|
+
obj = ctx.find_root().obj
|
|
74
|
+
if isinstance(obj, dict) and obj.get("config") is not None:
|
|
75
|
+
config: Config = obj["config"]
|
|
76
|
+
return config
|
|
77
|
+
try:
|
|
78
|
+
return fallback_config()
|
|
79
|
+
except Exception:
|
|
80
|
+
from deepctl_core import Config
|
|
81
|
+
|
|
82
|
+
return Config()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def install_help_notice(fallback_config: Callable[[], Config]) -> None:
|
|
86
|
+
"""Monkey-patch Click so every `--help` ends with the telemetry notice.
|
|
87
|
+
|
|
88
|
+
Click formats help via `Command.get_help(ctx)`. We wrap that method
|
|
89
|
+
once at startup so every command (including subcommands and plugin
|
|
90
|
+
commands) picks up the footer without needing per-command wiring. The
|
|
91
|
+
notice is rendered when help is shown, so it reflects `--config` and
|
|
92
|
+
`--no-telemetry`.
|
|
93
|
+
"""
|
|
94
|
+
global _installed
|
|
95
|
+
_fallback[:] = [fallback_config]
|
|
96
|
+
if _installed:
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
original_get_help = click.Command.get_help
|
|
100
|
+
|
|
101
|
+
def get_help_with_notice(self: click.Command, ctx: click.Context) -> str:
|
|
102
|
+
notice = render_notice(_config_for_help(ctx, _fallback[0]))
|
|
103
|
+
return original_get_help(self, ctx) + "\n\n" + notice
|
|
104
|
+
|
|
105
|
+
click.Command.get_help = get_help_with_notice # type: ignore[method-assign]
|
|
106
|
+
_installed = True
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deepctl-telemetry
|
|
3
|
-
Version: 0.0.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 0.0.8
|
|
4
|
+
Summary: Error reporting for deepctl; this version sends no telemetry
|
|
5
5
|
Author-email: Deepgram <devrel@deepgram.com>
|
|
6
6
|
Maintainer-email: Deepgram <devrel@deepgram.com>
|
|
7
7
|
License-Expression: MIT
|
|
@@ -14,8 +14,8 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.12
|
|
15
15
|
Requires-Python: >=3.10
|
|
16
16
|
Description-Content-Type: text/markdown
|
|
17
|
-
Requires-Dist: deepctl-core>=0.
|
|
18
|
-
Requires-Dist: sentry-sdk
|
|
17
|
+
Requires-Dist: deepctl-core>=0.3.0
|
|
18
|
+
Requires-Dist: sentry-sdk<3,>=2.35.0
|
|
19
19
|
Requires-Dist: click>=8.0.0
|
|
20
20
|
Requires-Dist: rich>=13.0.0
|
|
21
21
|
|
|
@@ -23,7 +23,7 @@ Requires-Dist: rich>=13.0.0
|
|
|
23
23
|
|
|
24
24
|
> Part of [deepctl](https://github.com/deepgram/cli) — Official Deepgram CLI
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
Error reporting for deepctl; this version sends no telemetry
|
|
27
27
|
|
|
28
28
|
This package provides internal APIs for deepctl and its command packages. It is not intended for direct use.
|
|
29
29
|
|
|
@@ -50,7 +50,7 @@ pipx run deepctl --help
|
|
|
50
50
|
|
|
51
51
|
## Dependencies
|
|
52
52
|
|
|
53
|
-
- `sentry-sdk>=2.
|
|
53
|
+
- `sentry-sdk>=2.35.0,<3`
|
|
54
54
|
- `click>=8.0.0`
|
|
55
55
|
- `rich>=13.0.0`
|
|
56
56
|
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
"""Opt-out phone-home telemetry for deepctl."""
|
|
2
|
-
|
|
3
|
-
from .client import init_telemetry, is_enabled
|
|
4
|
-
from .notice import install_help_notice, render_notice
|
|
5
|
-
|
|
6
|
-
__all__ = [
|
|
7
|
-
"init_telemetry",
|
|
8
|
-
"install_help_notice",
|
|
9
|
-
"is_enabled",
|
|
10
|
-
"render_notice",
|
|
11
|
-
]
|
|
12
|
-
|
|
13
|
-
__version__ = "0.0.7" # x-release-please-version
|
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
"""Sentry-backed telemetry client with strict opt-out."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import atexit
|
|
6
|
-
import os
|
|
7
|
-
import platform
|
|
8
|
-
import sys
|
|
9
|
-
from typing import TYPE_CHECKING, Any
|
|
10
|
-
|
|
11
|
-
if TYPE_CHECKING:
|
|
12
|
-
from deepctl_core import Config
|
|
13
|
-
from sentry_sdk.types import Event, Hint
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
SENTRY_DSN = (
|
|
17
|
-
"https://d7a2aabbf772218e3bbe89266999af70"
|
|
18
|
-
"@o206115.ingest.us.sentry.io/4510993603362816"
|
|
19
|
-
)
|
|
20
|
-
|
|
21
|
-
DSN_ENV_VAR = "DEEPCTL_TELEMETRY_DSN"
|
|
22
|
-
DISABLE_ENV_VAR = "DEEPCTL_TELEMETRY_DISABLED"
|
|
23
|
-
|
|
24
|
-
_initialized = False
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def is_enabled(config: Config) -> bool:
|
|
28
|
-
"""Whether telemetry should phone home for this invocation.
|
|
29
|
-
|
|
30
|
-
Resolution order: env override (DEEPCTL_TELEMETRY_DISABLED=1 wins),
|
|
31
|
-
then the user's config (`telemetry.enabled`, default `True`).
|
|
32
|
-
"""
|
|
33
|
-
if os.environ.get(DISABLE_ENV_VAR, "").lower() in {"1", "true", "yes"}:
|
|
34
|
-
return False
|
|
35
|
-
return bool(config.get("telemetry.enabled", True))
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def init_telemetry(config: Config) -> bool:
|
|
39
|
-
"""Initialize the Sentry SDK if telemetry is enabled.
|
|
40
|
-
|
|
41
|
-
Idempotent — safe to call multiple times. Returns whether init ran.
|
|
42
|
-
"""
|
|
43
|
-
global _initialized
|
|
44
|
-
if _initialized:
|
|
45
|
-
return True
|
|
46
|
-
if not is_enabled(config):
|
|
47
|
-
return False
|
|
48
|
-
|
|
49
|
-
dsn = os.environ.get(DSN_ENV_VAR) or SENTRY_DSN
|
|
50
|
-
|
|
51
|
-
try:
|
|
52
|
-
import sentry_sdk
|
|
53
|
-
except ImportError:
|
|
54
|
-
return False
|
|
55
|
-
|
|
56
|
-
try:
|
|
57
|
-
cli_version = _read_cli_version()
|
|
58
|
-
except Exception:
|
|
59
|
-
cli_version = "unknown"
|
|
60
|
-
|
|
61
|
-
sentry_sdk.init(
|
|
62
|
-
dsn=dsn,
|
|
63
|
-
release=f"deepctl@{cli_version}",
|
|
64
|
-
environment="production",
|
|
65
|
-
send_default_pii=False,
|
|
66
|
-
auto_session_tracking=True,
|
|
67
|
-
traces_sample_rate=1.0,
|
|
68
|
-
profiles_sample_rate=1.0,
|
|
69
|
-
enable_logs=True,
|
|
70
|
-
attach_stacktrace=True,
|
|
71
|
-
max_breadcrumbs=100,
|
|
72
|
-
before_send=_scrub_event,
|
|
73
|
-
)
|
|
74
|
-
|
|
75
|
-
sentry_sdk.set_tag("cli.os", platform.system().lower())
|
|
76
|
-
sentry_sdk.set_tag("cli.arch", platform.machine().lower())
|
|
77
|
-
sentry_sdk.set_tag(
|
|
78
|
-
"cli.python",
|
|
79
|
-
f"{sys.version_info.major}.{sys.version_info.minor}",
|
|
80
|
-
)
|
|
81
|
-
sentry_sdk.set_tag("cli.version", cli_version)
|
|
82
|
-
|
|
83
|
-
sentry_sdk.start_session()
|
|
84
|
-
atexit.register(_flush_on_exit)
|
|
85
|
-
|
|
86
|
-
_initialized = True
|
|
87
|
-
return True
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def _flush_on_exit() -> None:
|
|
91
|
-
"""Flush queued envelopes before process exit (best-effort, 2s budget)."""
|
|
92
|
-
try:
|
|
93
|
-
import sentry_sdk
|
|
94
|
-
|
|
95
|
-
sentry_sdk.flush(timeout=2.0)
|
|
96
|
-
except Exception:
|
|
97
|
-
pass
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def _read_cli_version() -> str:
|
|
101
|
-
import importlib.metadata
|
|
102
|
-
|
|
103
|
-
return importlib.metadata.version("deepctl")
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
_MCP_NOISE_LOGGERS = frozenset(
|
|
107
|
-
{
|
|
108
|
-
"mcp.client.streamable_http",
|
|
109
|
-
"mcp.server.lowlevel.server",
|
|
110
|
-
}
|
|
111
|
-
)
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def _is_mcp_transient_noise(event: Event) -> bool:
|
|
115
|
-
"""Identify log events from the MCP SDK that are not actionable bugs.
|
|
116
|
-
|
|
117
|
-
`dg mcp` embeds the upstream `mcp` Python SDK, which logs to Sentry via
|
|
118
|
-
the logging integration whenever the upstream MCP server returns 5xx
|
|
119
|
-
(handled by the client) or the stdio peer closes mid-message (handled by
|
|
120
|
-
the server). Both are recovered internally. Surfacing them as Sentry
|
|
121
|
-
issues just creates triage cost for the DX team and hides real CLI bugs.
|
|
122
|
-
|
|
123
|
-
Any unhandled exception is kept regardless of logger.
|
|
124
|
-
"""
|
|
125
|
-
if (event.get("logger") or "") not in _MCP_NOISE_LOGGERS:
|
|
126
|
-
return False
|
|
127
|
-
for exc in (event.get("exception") or {}).get("values") or []:
|
|
128
|
-
mechanism = exc.get("mechanism") or {}
|
|
129
|
-
if mechanism.get("handled") is False:
|
|
130
|
-
return False
|
|
131
|
-
return True
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
def _is_broken_pipe(event: Event) -> bool:
|
|
135
|
-
"""Identify events caused by writing to a closed/broken output stream.
|
|
136
|
-
|
|
137
|
-
When `dg mcp` runs as an MCP server, the host can close stdio while the
|
|
138
|
-
CLI is still finishing up. Any residual write (an advisory notification, a
|
|
139
|
-
console error message) then raises ``BrokenPipeError`` — or rich's
|
|
140
|
-
``ValueError: I/O operation on closed file`` when the underlying stream is
|
|
141
|
-
already closed. Neither is an actionable CLI bug, so drop them rather than
|
|
142
|
-
page the DX team.
|
|
143
|
-
"""
|
|
144
|
-
for exc in (event.get("exception") or {}).get("values") or []:
|
|
145
|
-
exc_type = exc.get("type") or ""
|
|
146
|
-
if exc_type == "BrokenPipeError":
|
|
147
|
-
return True
|
|
148
|
-
if exc_type == "ValueError" and "closed file" in (exc.get("value") or ""):
|
|
149
|
-
return True
|
|
150
|
-
return False
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
def _scrub_event(event: Event, _hint: Hint) -> Event | None:
|
|
154
|
-
"""Drop request bodies, headers, and any user-identifying data.
|
|
155
|
-
|
|
156
|
-
Sentry SDK already filters most PII via send_default_pii=False, but
|
|
157
|
-
Auth tokens, project IDs, and file paths can still leak through
|
|
158
|
-
breadcrumbs and exception messages. This is a defense-in-depth scrub.
|
|
159
|
-
|
|
160
|
-
Also drops known-noise events (see ``_is_mcp_transient_noise`` and
|
|
161
|
-
``_is_broken_pipe``).
|
|
162
|
-
"""
|
|
163
|
-
if _is_mcp_transient_noise(event) or _is_broken_pipe(event):
|
|
164
|
-
return None
|
|
165
|
-
|
|
166
|
-
request: dict[str, Any] = event.get("request") or {}
|
|
167
|
-
if "headers" in request:
|
|
168
|
-
request["headers"] = {}
|
|
169
|
-
if "cookies" in request:
|
|
170
|
-
request["cookies"] = {}
|
|
171
|
-
if "data" in request:
|
|
172
|
-
request["data"] = "[Filtered]"
|
|
173
|
-
|
|
174
|
-
user: dict[str, Any] = event.get("user") or {}
|
|
175
|
-
user.pop("email", None)
|
|
176
|
-
user.pop("ip_address", None)
|
|
177
|
-
user.pop("username", None)
|
|
178
|
-
|
|
179
|
-
return event
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
"""Subtle telemetry notice appended to `--help` output."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from typing import TYPE_CHECKING
|
|
6
|
-
|
|
7
|
-
import click
|
|
8
|
-
|
|
9
|
-
if TYPE_CHECKING:
|
|
10
|
-
from deepctl_core import Config
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
# There is no `dg config` command, so the notice names the two opt-outs that
|
|
14
|
-
# actually exist: the env var for one run, and the config file to persist it.
|
|
15
|
-
NOTICE_ON = (
|
|
16
|
-
"Telemetry is on (anonymous error reports). "
|
|
17
|
-
"Disable: DEEPCTL_TELEMETRY_DISABLED=1, "
|
|
18
|
-
"or set telemetry.enabled: false in your deepctl config.yaml"
|
|
19
|
-
)
|
|
20
|
-
NOTICE_OFF = "Telemetry is off."
|
|
21
|
-
|
|
22
|
-
_installed = False
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def render_notice(config: Config) -> str:
|
|
26
|
-
"""Return the dim one-line notice for the current telemetry state."""
|
|
27
|
-
from .client import is_enabled
|
|
28
|
-
|
|
29
|
-
text = NOTICE_ON if is_enabled(config) else NOTICE_OFF
|
|
30
|
-
return click.style(text, dim=True)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def install_help_notice(config: Config) -> None:
|
|
34
|
-
"""Monkey-patch Click so every `--help` ends with the telemetry notice.
|
|
35
|
-
|
|
36
|
-
Click formats help via `Command.get_help(ctx)`. We wrap that method
|
|
37
|
-
once at startup so every command (including subcommands and plugin
|
|
38
|
-
commands) picks up the footer without needing per-command wiring.
|
|
39
|
-
"""
|
|
40
|
-
global _installed
|
|
41
|
-
if _installed:
|
|
42
|
-
return
|
|
43
|
-
|
|
44
|
-
original_get_help = click.Command.get_help
|
|
45
|
-
notice = render_notice(config)
|
|
46
|
-
|
|
47
|
-
def get_help_with_notice(self: click.Command, ctx: click.Context) -> str:
|
|
48
|
-
return original_get_help(self, ctx) + "\n\n" + notice
|
|
49
|
-
|
|
50
|
-
click.Command.get_help = get_help_with_notice # type: ignore[method-assign]
|
|
51
|
-
_installed = True
|
|
File without changes
|
{deepctl_telemetry-0.0.7 → deepctl_telemetry-0.0.8}/src/deepctl_telemetry.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{deepctl_telemetry-0.0.7 → deepctl_telemetry-0.0.8}/src/deepctl_telemetry.egg-info/top_level.txt
RENAMED
|
File without changes
|