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.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepctl-telemetry
3
- Version: 0.0.7
4
- Summary: Opt-out phone-home telemetry for deepctl
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.1.10
18
- Requires-Dist: sentry-sdk>=2.0.0
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
- Opt-out phone-home telemetry for deepctl
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.0.0`
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
- Opt-out phone-home telemetry for deepctl
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.0.0`
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.7" # x-release-please-version
8
- description = "Opt-out phone-home telemetry for deepctl"
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.1.10",
25
- "sentry-sdk>=2.0.0",
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.7
4
- Summary: Opt-out phone-home telemetry for deepctl
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.1.10
18
- Requires-Dist: sentry-sdk>=2.0.0
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
- Opt-out phone-home telemetry for deepctl
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.0.0`
53
+ - `sentry-sdk>=2.35.0,<3`
54
54
  - `click>=8.0.0`
55
55
  - `rich>=13.0.0`
56
56
 
@@ -0,0 +1,4 @@
1
+ deepctl-core>=0.3.0
2
+ sentry-sdk<3,>=2.35.0
3
+ click>=8.0.0
4
+ rich>=13.0.0
@@ -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
@@ -1,4 +0,0 @@
1
- deepctl-core>=0.1.10
2
- sentry-sdk>=2.0.0
3
- click>=8.0.0
4
- rich>=13.0.0