Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/.release-please-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
".": "0.2.20",
"packages/deepctl-core": "0.2.9",
"packages/deepctl-shared-utils": "0.1.11",
"packages/deepctl-telemetry": "0.0.1",
"packages/deepctl-cmd-api": "0.0.2",
"packages/deepctl-cmd-login": "0.1.14",
"packages/deepctl-cmd-projects": "0.1.11",
Expand Down
8 changes: 8 additions & 0 deletions .github/release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@
"src/deepctl_shared_utils/__init__.py"
]
},
"packages/deepctl-telemetry": {
"component": "deepctl-telemetry",
"include-component-in-tag": true,
"make-latest": false,
"extra-files": [
"src/deepctl_telemetry/__init__.py"
]
},
"packages/deepctl-cmd-api": {
"component": "deepctl-cmd-api",
"include-component-in-tag": true,
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ jobs:

- name: Run tests
run: |
uv run pytest tests packages/deepctl-core/tests packages/deepctl-shared-utils/tests packages/deepctl-cmd-api/tests packages/deepctl-cmd-login/tests packages/deepctl-cmd-projects/tests packages/deepctl-cmd-transcribe/tests packages/deepctl-cmd-usage/tests packages/deepctl-cmd-debug/tests packages/deepctl-cmd-debug-audio/tests packages/deepctl-cmd-debug-browser/tests packages/deepctl-cmd-debug-network/tests packages/deepctl-cmd-debug-probe/tests packages/deepctl-cmd-ffprobe/tests packages/deepctl-cmd-mcp/tests packages/deepctl-cmd-update/tests packages/deepctl-cmd-plugin/tests packages/deepctl-cmd-skills/tests packages/deepctl-cmd-init/tests packages/deepctl-cmd-models/tests packages/deepctl-cmd-speak/tests packages/deepctl-cmd-keys/tests packages/deepctl-cmd-read/tests packages/deepctl-cmd-listen/tests packages/deepctl-cmd-requests/tests packages/deepctl-cmd-billing/tests packages/deepctl-cmd-members/tests packages/deepctl-cmd-completion/tests packages/deepctl-plugin-example/tests --cov --cov-report=term-missing -v
uv run pytest tests packages/deepctl-core/tests packages/deepctl-shared-utils/tests packages/deepctl-telemetry/tests packages/deepctl-cmd-api/tests packages/deepctl-cmd-login/tests packages/deepctl-cmd-projects/tests packages/deepctl-cmd-transcribe/tests packages/deepctl-cmd-usage/tests packages/deepctl-cmd-debug/tests packages/deepctl-cmd-debug-audio/tests packages/deepctl-cmd-debug-browser/tests packages/deepctl-cmd-debug-network/tests packages/deepctl-cmd-debug-probe/tests packages/deepctl-cmd-ffprobe/tests packages/deepctl-cmd-mcp/tests packages/deepctl-cmd-update/tests packages/deepctl-cmd-plugin/tests packages/deepctl-cmd-skills/tests packages/deepctl-cmd-init/tests packages/deepctl-cmd-models/tests packages/deepctl-cmd-speak/tests packages/deepctl-cmd-keys/tests packages/deepctl-cmd-read/tests packages/deepctl-cmd-listen/tests packages/deepctl-cmd-requests/tests packages/deepctl-cmd-billing/tests packages/deepctl-cmd-members/tests packages/deepctl-cmd-completion/tests packages/deepctl-plugin-example/tests --cov --cov-report=term-missing -v

- name: Test CLI installation
run: |
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,8 @@ cli/
│ ├── deepctl-cmd-usage/ # Usage command for deepctl
│ ├── deepctl-core/ # Core components for deepctl
│ ├── deepctl-plugin-example/ # Example plugin for deepctl
│ └── deepctl-shared-utils/ # Shared utilities for deepctl
│ ├── deepctl-shared-utils/ # Shared utilities for deepctl
│ └── deepctl-telemetry/ # Opt-out phone-home telemetry for deepctl
├── tests/ # Integration tests
└── Makefile # Development tasks
```
Expand Down Expand Up @@ -441,6 +442,7 @@ cli/
| [`deepctl-core`](packages/deepctl-core) | Core components for deepctl |
| [`deepctl-plugin-example`](packages/deepctl-plugin-example) | Example plugin for deepctl |
| [`deepctl-shared-utils`](packages/deepctl-shared-utils) | Shared utilities for deepctl |
| [`deepctl-telemetry`](packages/deepctl-telemetry) | Opt-out phone-home telemetry for deepctl |
<!-- END:packages -->

## Release
Expand Down
7 changes: 7 additions & 0 deletions packages/deepctl-core/src/deepctl_core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ class ToolsConfig(BaseModel):
ffprobe_path: str | None = None


class TelemetryConfig(BaseModel):
"""Phone-home telemetry configuration. Opt-out — defaults to on."""

enabled: bool = True


class DeepgramConfig(BaseModel):
"""Main configuration model."""

Expand All @@ -64,6 +70,7 @@ class DeepgramConfig(BaseModel):
plugins: PluginConfig = Field(default_factory=PluginConfig)
update: UpdateConfig = Field(default_factory=UpdateConfig)
tools: ToolsConfig = Field(default_factory=ToolsConfig)
telemetry: TelemetryConfig = Field(default_factory=TelemetryConfig)


class Config:
Expand Down
1 change: 1 addition & 0 deletions packages/deepctl-telemetry/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Changelog
38 changes: 38 additions & 0 deletions packages/deepctl-telemetry/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# deepctl-telemetry

> Part of [deepctl](https://github.com/deepgram/cli) — Official Deepgram CLI

Opt-out phone-home telemetry for deepctl

This package provides internal APIs for deepctl and its command packages. It is not intended for direct use.

## Installation

This package is included with deepctl and does not need to be installed separately.

### Install deepctl

```bash
# Install with pip
pip install deepctl

# Or install with uv
uv tool install deepctl

# Or install with pipx
pipx install deepctl

# Or run without installing
uvx deepctl --help
pipx run deepctl --help
```

## Dependencies

- `sentry-sdk>=2.0.0`
- `click>=8.0.0`
- `rich>=13.0.0`

## License

MIT — see [LICENSE](../../LICENSE)
38 changes: 38 additions & 0 deletions packages/deepctl-telemetry/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "deepctl-telemetry"
version = "0.0.1" # x-release-please-version
description = "Opt-out phone-home telemetry for deepctl"
readme = "README.md"
license = "MIT"
authors = [{ name = "Deepgram", email = "devrel@deepgram.com" }]
maintainers = [{ name = "Deepgram", email = "devrel@deepgram.com" }]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
keywords = ["deepgram", "cli", "telemetry", "sentry"]
requires-python = ">=3.10"
dependencies = [
"deepctl-core>=0.1.10",
"sentry-sdk>=2.0.0",
"click>=8.0.0",
"rich>=13.0.0",
]

[tool.setuptools]
package-dir = { "" = "src" }

[tool.setuptools.packages.find]
where = ["src"]
include = ["deepctl_telemetry*"]

[tool.uv.sources]
deepctl-core = { workspace = true }
13 changes: 13 additions & 0 deletions packages/deepctl-telemetry/src/deepctl_telemetry/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Opt-out phone-home telemetry for deepctl."""

from .client import init_telemetry, is_enabled
from .notice import install_help_notice, render_notice

__all__ = [
"init_telemetry",
"install_help_notice",
"is_enabled",
"render_notice",
]

__version__ = "0.0.1" # x-release-please-version
109 changes: 109 additions & 0 deletions packages/deepctl-telemetry/src/deepctl_telemetry/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""Sentry-backed telemetry client with strict opt-out."""

from __future__ import annotations

import os
import platform
import sys
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from deepctl_core import Config
from sentry_sdk.types import Event, Hint


SENTRY_DSN = (
"https://d7a2aabbf772218e3bbe89266999af70"
"@o206115.ingest.us.sentry.io/4510993603362816"
)

DSN_ENV_VAR = "DEEPCTL_TELEMETRY_DSN"
DISABLE_ENV_VAR = "DEEPCTL_TELEMETRY_DISABLED"

_initialized = False


def is_enabled(config: Config) -> bool:
"""Whether telemetry should phone home for this invocation.

Resolution order: env override (DEEPCTL_TELEMETRY_DISABLED=1 wins),
then the user's config (`telemetry.enabled`, default `True`).
"""
if os.environ.get(DISABLE_ENV_VAR, "").lower() in {"1", "true", "yes"}:
return False
return bool(config.get("telemetry.enabled", True))


def init_telemetry(config: Config) -> bool:
"""Initialize the Sentry SDK if telemetry is enabled.

Idempotent — safe to call multiple times. Returns whether init ran.
"""
global _initialized
if _initialized:
return True
if not is_enabled(config):
Comment on lines +37 to +45
return False

dsn = os.environ.get(DSN_ENV_VAR) or SENTRY_DSN

try:
import sentry_sdk
except ImportError:
return False

try:
cli_version = _read_cli_version()
except Exception:
cli_version = "unknown"

sentry_sdk.init(
dsn=dsn,
release=f"deepctl@{cli_version}",
environment="production",
send_default_pii=False,
traces_sample_rate=0.0,
profiles_sample_rate=0.0,
max_breadcrumbs=20,
before_send=_scrub_event,
)

sentry_sdk.set_tag("cli.os", platform.system().lower())
sentry_sdk.set_tag("cli.arch", platform.machine().lower())
sentry_sdk.set_tag(
"cli.python",
f"{sys.version_info.major}.{sys.version_info.minor}",
)
sentry_sdk.set_tag("cli.version", cli_version)

_initialized = True
return True


def _read_cli_version() -> str:
import importlib.metadata

return importlib.metadata.version("deepctl")


def _scrub_event(event: Event, _hint: Hint) -> Event | None:
"""Drop request bodies, headers, and any user-identifying data.

Sentry SDK already filters most PII via send_default_pii=False, but
Auth tokens, project IDs, and file paths can still leak through
breadcrumbs and exception messages. This is a defense-in-depth scrub.
"""
request: dict[str, Any] = event.get("request") or {}
if "headers" in request:
request["headers"] = {}
if "cookies" in request:
request["cookies"] = {}
if "data" in request:
request["data"] = "[Filtered]"

user: dict[str, Any] = event.get("user") or {}
user.pop("email", None)
user.pop("ip_address", None)
user.pop("username", None)

return event
48 changes: 48 additions & 0 deletions packages/deepctl-telemetry/src/deepctl_telemetry/notice.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Subtle telemetry notice appended to `--help` output."""

from __future__ import annotations

from typing import TYPE_CHECKING

import click

if TYPE_CHECKING:
from deepctl_core import Config


NOTICE_ON = (
"Telemetry is on (anonymous error reports). "
"Disable: dg config set telemetry.enabled false"
)
NOTICE_OFF = "Telemetry is off."

_installed = False


def render_notice(config: Config) -> str:
"""Return the dim one-line notice for the current telemetry state."""
from .client import is_enabled

text = NOTICE_ON if is_enabled(config) else NOTICE_OFF
return click.style(text, dim=True)


def install_help_notice(config: Config) -> None:
"""Monkey-patch Click so every `--help` ends with the telemetry notice.

Click formats help via `Command.get_help(ctx)`. We wrap that method
once at startup so every command (including subcommands and plugin
commands) picks up the footer without needing per-command wiring.
"""
global _installed
if _installed:
return

original_get_help = click.Command.get_help
notice = render_notice(config)

def get_help_with_notice(self: click.Command, ctx: click.Context) -> str:
return original_get_help(self, ctx) + "\n\n" + notice

click.Command.get_help = get_help_with_notice # type: ignore[method-assign]
_installed = True
Empty file.
Empty file.
37 changes: 37 additions & 0 deletions packages/deepctl-telemetry/tests/unit/test_telemetry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Tests for deepctl_telemetry."""

from __future__ import annotations

from unittest.mock import Mock

from deepctl_telemetry import is_enabled, render_notice
from deepctl_telemetry.client import DISABLE_ENV_VAR


def _config(value: bool | None = True) -> Mock:
config = Mock()
config.get.return_value = value
return config


class TestIsEnabled:
def test_default_on(self) -> None:
assert is_enabled(_config(True)) is True

def test_config_off(self) -> None:
assert is_enabled(_config(False)) is False

def test_env_override(self, monkeypatch: object) -> None:
monkeypatch.setenv(DISABLE_ENV_VAR, "1") # type: ignore[attr-defined]
assert is_enabled(_config(True)) is False


class TestRenderNotice:
def test_on_message(self) -> None:
notice = render_notice(_config(True))
assert "Telemetry is on" in notice
assert "telemetry.enabled false" in notice

def test_off_message(self) -> None:
notice = render_notice(_config(False))
assert "Telemetry is off" in notice
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ dependencies = [
"deepctl-cmd-members>=0.0.1",
"deepctl-cmd-completion>=0.0.1",
"deepctl-shared-utils>=0.1.10",
"deepctl-telemetry>=0.0.1",
"pydantic>=2.0.0",
"rich>=13.0.0",
"httpx>=0.24.0",
Expand Down Expand Up @@ -213,6 +214,7 @@ deepctl-cmd-billing = { workspace = true }
deepctl-cmd-members = { workspace = true }
deepctl-cmd-completion = { workspace = true }
deepctl-shared-utils = { workspace = true }
deepctl-telemetry = { workspace = true }
deepctl-plugin-example = { workspace = true }

[tool.uv.workspace]
Expand Down
14 changes: 14 additions & 0 deletions src/deepctl/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,20 @@ def main() -> None:
if timing_requested:
enable_timing()

# Initialize phone-home telemetry and install the --help footer.
# Both are no-ops when the user has opted out via config or env.
try:
from deepctl_telemetry import (
init_telemetry,
install_help_notice,
)

telemetry_config = Config()
init_telemetry(telemetry_config)
install_help_notice(telemetry_config)
Comment on lines +244 to +246
except Exception:
pass

# Start background update checks (non-blocking)
try:
from deepctl_cmd_update.startup_check import (
Expand Down
Loading
Loading