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
6 changes: 4 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,13 @@ RATE_LIMIT_REGISTER_PER_HOUR=3
# OIDC_ALLOWED_DOMAINS=example.com
# OIDC_AUTO_CREATE_USERS=true

# Opt-in anonymous usage telemetry: one small ping a day (instance id, version,
# Anonymous usage telemetry: one small ping a day (instance id, version,
# edition, warehouse engines, bucketed counts), never names, emails, hosts,
# queries or data. Every field: website/docs/run/telemetry.md.
# queries or data. Every field: website/docs/run/telemetry.md. Unset, it is on
# in Community and off in Enterprise; DO_NOT_TRACK=1 turns it off regardless.
# TELEMETRY_ENABLED=false
# TELEMETRY_ENDPOINT=https://telemetry.tripl.io/v1/ping
# DO_NOT_TRACK=1
# Enterprise: a public demo instance (website/docs/run/public-demo.md): PUBLIC_DEMO=true
# refuses whatever would reach outside the instance, and with
# DEPLOYMENT_MODE=hosted IDLE_ORG_RETENTION_DAYS (Enterprise) deletes organizations nobody
Expand Down
4 changes: 2 additions & 2 deletions backend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -31530,7 +31530,7 @@
"type": "object"
},
"TelemetryStatusResponse": {
"description": "``GET /platform/settings/telemetry``: the opt-in usage ping, and the last one sent.",
"description": "``GET /platform/settings/telemetry``: the usage ping, and the last one sent.",
"properties": {
"enabled": {
"title": "Enabled",
Expand Down Expand Up @@ -35412,7 +35412,7 @@
},
"/api/v1/platform/settings/telemetry": {
"get": {
"description": "The opt-in usage ping: whether it is sent, where to, and exactly what it last held.",
"description": "The usage ping: whether it is sent (and if not, why), where to, and what it last held.",
"operationId": "get_telemetry_api_v1_platform_settings_telemetry_get",
"responses": {
"200": {
Expand Down
2 changes: 1 addition & 1 deletion backend/src/tripl/api/v1/platform_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,5 +99,5 @@ async def get_telemetry(
session: SessionDep,
_current_user: PlatformAdminUserDep,
) -> TelemetryStatusResponse:
"""The opt-in usage ping: whether it is sent, where to, and exactly what it last held."""
"""The usage ping: whether it is sent (and if not, why), where to, and what it last held."""
return TelemetryStatusResponse.model_validate(await telemetry_service.status(session))
13 changes: 8 additions & 5 deletions backend/src/tripl/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,11 +324,14 @@ class Settings(BaseSettings):
oidc_button_label: str = "Sign in with OpenID Connect"
oidc_allowed_domains: Annotated[list[str], NoDecode] = Field(default_factory=list)
oidc_auto_create_users: bool = True
# Opt-in anonymous usage telemetry (services/telemetry_service.py): off
# unless set; then one small JSON ping a day — instance id, version,
# edition, warehouse engines, bucketed counts — and never names, emails,
# hosts, queries or data. website/docs/run/telemetry.md lists every field.
telemetry_enabled: bool = False
# Anonymous usage telemetry (services/telemetry_service.py): one small JSON
# ping a day — instance id, version, edition, warehouse engines, bucketed
# counts — and never names, emails, hosts, queries or data.
# website/docs/run/telemetry.md lists every field. Unset means the
# edition's default: on in Community, off in Enterprise. DO_NOT_TRACK
# (the cross-tool opt-out) turns it off whatever this says.
telemetry_enabled: bool | None = None
do_not_track: bool = False
telemetry_endpoint: str = "https://telemetry.tripl.io/v1/ping"

# AI features (LLM-powered descriptions, Q&A). Disabled by default because
Expand Down
2 changes: 2 additions & 0 deletions backend/src/tripl/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
from tripl.middleware.request_id import bound_request_id, request_id_from_scope # noqa: E402
from tripl.middleware.security_headers import build_security_headers # noqa: E402
from tripl.observability.metrics import render_metrics # noqa: E402
from tripl.services import telemetry_service # noqa: E402

# Configure logging now (after overrides are applied) so every log line — including
# those emitted while building the app and importing routers, before the async
Expand All @@ -60,6 +61,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
# An extension's cipher (a key management service, say) proves itself now
# rather than at the first secret a request reads. Default cipher: no-op.
crypto.check_cipher()
telemetry_service.log_startup_notice()
logger.info(
"tripl starting",
extra={
Expand Down
5 changes: 3 additions & 2 deletions backend/src/tripl/schemas/app_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -630,10 +630,11 @@ class OrgTrackerDefaultsUpdate(BaseModel):


class TelemetryStatusResponse(BaseModel):
"""``GET /platform/settings/telemetry``: the opt-in usage ping, and the last one sent."""
"""``GET /platform/settings/telemetry``: the usage ping, and the last one sent."""

enabled: bool
#: Why nothing is sent (``disabled``, ``no endpoint``, ``public demo``); null when it is.
#: Why nothing is sent (``do not track``, ``disabled``, ``enterprise default``,
#: ``no endpoint``, ``public demo``); null when it is.
reason: str | None = None
endpoint: str
instance_id: str | None = None
Expand Down
32 changes: 27 additions & 5 deletions backend/src/tripl/services/telemetry_service.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"""Opt-in anonymous usage telemetry: one small ping a day.
"""Anonymous usage telemetry: one small ping a day.

Off unless the operator turns it on (``TELEMETRY_ENABLED=true``; ``tripl
install`` asks once). Then the ``send-telemetry`` beat task POSTs one JSON
document a day to ``TELEMETRY_ENDPOINT``. What it holds is everything there is
On by default in Community, off by default in Enterprise; ``TELEMETRY_ENABLED``
says otherwise, and ``DO_NOT_TRACK`` turns it off whatever that says. The API
logs at startup that it is on, and how to turn it off. While it is on, the
``send-telemetry`` beat task POSTs one JSON document a day to
``TELEMETRY_ENDPOINT``. What it holds is everything there is
(``website/docs/run/telemetry.md`` lists it, and **Settings → Platform →
Runtime** shows the last one sent):

Expand Down Expand Up @@ -72,17 +74,37 @@ def edition() -> str:
return "enterprise" if extensions.extensions() else "community"


TELEMETRY_DOCS_URL = "https://docs.tripl.io/run/telemetry"


def inactive_reason() -> str | None:
"""Why nothing is sent, or None when the ping goes out."""
if not settings.telemetry_enabled:
if settings.do_not_track:
return "do not track"
if settings.telemetry_enabled is False:
return "disabled"
if settings.telemetry_enabled is None and edition() == "enterprise":
return "enterprise default"
if not settings.telemetry_endpoint:
return "no endpoint"
if tenancy.public_demo():
return "public demo"
return None


def log_startup_notice() -> None:
"""Say plainly, once at startup, that the ping is on and how to turn it off."""
if inactive_reason() is not None:
return
logger.warning(
"Anonymous usage telemetry is ON: one small ping a day to %s - no names, "
"emails, hosts, queries or data. What it holds: %s. "
"Turn it off with TELEMETRY_ENABLED=false (or DO_NOT_TRACK=1) and restart.",
settings.telemetry_endpoint,
TELEMETRY_DOCS_URL,
)


def _operator_row() -> Any:
return select(AppSetting).where(
AppSetting.key == SETTING_KEY, AppSetting.organization_id.is_(None)
Expand Down
56 changes: 49 additions & 7 deletions backend/src/tripl/tests/test_telemetry.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
"""Opt-in anonymous usage telemetry (``telemetry_service``).
"""Anonymous usage telemetry (``telemetry_service``).

Off by default; on, one ping a day with nothing that names anyone, counts only
as ranges, and the last one kept for the operator to read. A public demo
sends nothing, and a receiver that is down changes nothing.
On by default in Community, off in Enterprise and under DO_NOT_TRACK; on, one
ping a day with nothing that names anyone, counts only as ranges, and the last
one kept for the operator to read. A public demo sends nothing, and a receiver
that is down changes nothing.
"""

from __future__ import annotations

import json
import logging
from datetime import UTC, datetime, timedelta
from typing import Any

Expand Down Expand Up @@ -69,13 +71,52 @@ def test_the_edition_is_enterprise_once_an_extension_loads() -> None:
assert telemetry_service.edition() == "enterprise"


async def test_off_by_default_nothing_is_sent_or_stored(receiver: Receiver) -> None:
assert settings.telemetry_enabled is False
async def test_turned_off_nothing_is_sent_or_stored(
receiver: Receiver, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "telemetry_enabled", False)
assert await _send() == {"sent": False, "reason": "disabled"}
assert receiver.pings == []
assert await _rows() == []


def test_unset_it_is_on_in_community_and_off_in_enterprise(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "telemetry_enabled", None)
with override_extensions([]):
assert telemetry_service.inactive_reason() is None
with override_extensions([Extension()]):
assert telemetry_service.inactive_reason() == "enterprise default"
monkeypatch.setattr(settings, "telemetry_enabled", True)
assert telemetry_service.inactive_reason() is None


def test_do_not_track_wins_over_everything(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "telemetry_enabled", True)
monkeypatch.setattr(settings, "do_not_track", True)
assert telemetry_service.inactive_reason() == "do not track"


def test_startup_says_it_is_on_and_how_to_turn_it_off(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
monkeypatch.setattr(settings, "telemetry_enabled", True)
with caplog.at_level(logging.WARNING, logger=telemetry_service.__name__):
telemetry_service.log_startup_notice()
(record,) = caplog.records
message = record.getMessage()
assert "telemetry is ON" in message
assert "TELEMETRY_ENABLED=false" in message
assert "DO_NOT_TRACK=1" in message
assert "https://docs.tripl.io/run/telemetry" in message

caplog.clear()
monkeypatch.setattr(settings, "telemetry_enabled", False)
telemetry_service.log_startup_notice()
assert caplog.records == []


async def test_a_ping_names_nobody_and_is_kept_for_the_operator(
client: AsyncClient, receiver: Receiver, monkeypatch: pytest.MonkeyPatch
) -> None:
Expand Down Expand Up @@ -139,7 +180,8 @@ async def test_a_public_demo_sends_nothing(
assert receiver.pings == []


async def test_only_a_platform_admin_reads_it() -> None:
async def test_only_a_platform_admin_reads_it(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "telemetry_enabled", False)
world = await build_world()
try:
assert (await world.bob.get(STATUS)).status_code == 403
Expand Down
2 changes: 1 addition & 1 deletion backend/src/tripl/worker/celery_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@
},
"send-telemetry": {
"task": "tripl.worker.tasks.telemetry.send_telemetry",
# Daily: the opt-in usage ping (TELEMETRY_ENABLED); a no-op otherwise.
# Daily: the usage ping (telemetry_service.inactive_reason); a no-op when off.
# Its counts are day-scale, so once a day says all there is.
"schedule": crontab(hour=7, minute=13),
},
Expand Down
2 changes: 1 addition & 1 deletion backend/src/tripl/worker/tasks/telemetry.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""The daily opt-in usage ping (``telemetry_service``): a no-op unless enabled."""
"""The daily usage ping (``telemetry_service``): a no-op while it is off."""

from __future__ import annotations

Expand Down
28 changes: 22 additions & 6 deletions cli/src/tripl_cli/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
from tripl_cli.errors import EXIT_FAILURE, EXIT_OK, TriplConfigError, TriplError
from tripl_cli.install import docker, files, secrets
from tripl_cli.install.health import HealthOutcome, read_bootstrap, wait_for_health
from tripl_cli.install.plan import APPEND, FileWrite, InstallPlan
from tripl_cli.install.plan import APPEND, CREATE, FileWrite, InstallPlan
from tripl_cli.install.render import (
render_directory_header,
render_health,
Expand Down Expand Up @@ -132,11 +132,13 @@ def register(
parser.add_argument(
"--telemetry",
dest="telemetry",
action="store_true",
action=argparse.BooleanOptionalAction,
default=None,
help=(
"share anonymous usage telemetry: one small ping a day, no names, emails, hosts "
"or data (TELEMETRY_ENABLED in the new .env; default: off). Asked once, here: an "
"existing .env is never rewritten"
"anonymous usage telemetry: one small ping a day, no names, emails, hosts or "
"data. Neither flag leaves the edition's default (on in community, off in "
"enterprise); either writes TELEMETRY_ENABLED into the new .env. Asked once, "
"here: an existing .env is never rewritten"
),
)
add_wait_flag(parser)
Expand Down Expand Up @@ -375,7 +377,7 @@ def run_install(args: argparse.Namespace, config: Config) -> int:
generated_at=generated_at,
values=values,
force=bool(args.force),
telemetry=bool(args.telemetry),
telemetry=args.telemetry,
)
# What will be TRUE when this finishes, which on a re-run is the existing
# .env's values - that file is never overwritten. Everything downstream (the
Expand Down Expand Up @@ -438,6 +440,20 @@ def run_install(args: argparse.Namespace, config: Config) -> int:
"with the credentials that came with your subscription before the pull.",
file=sys.stderr,
)
creating_env = any(
write.action == CREATE and write.path.name == files.ENV_NAME for write in writes
)
telemetry_on = args.telemetry is True or (
args.telemetry is None and plan.image != files.ENTERPRISE_IMAGE
)
if creating_env and telemetry_on:
print(
"tripl: note: anonymous usage telemetry is on: one small ping a day, no names, "
f"emails, hosts or data ({files.TELEMETRY_DOCS_URL}). Turn it off with "
"TELEMETRY_ENABLED=false in .env, or re-run with --no-telemetry before the "
".env exists.",
file=sys.stderr,
)
if plan.insecure_scheme:
# Loud, and not a refusal: an operator terminating TLS elsewhere or
# evaluating on a laptop has a legitimate http:// origin.
Expand Down
11 changes: 7 additions & 4 deletions cli/src/tripl_cli/install/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ def render_env(
version: str,
generated_at: datetime,
values: Mapping[str, str],
telemetry: bool = False,
telemetry: bool | None = None,
) -> str:
"""The generated ``.env``, in full.

Expand Down Expand Up @@ -218,7 +218,10 @@ def render_env(
"",
"# Anonymous usage telemetry: one small ping a day, no names, emails, hosts or",
f"# data. What it holds: {TELEMETRY_DOCS_URL}",
env_line("TELEMETRY_ENABLED", "true" if telemetry else "false"),
"# Unset, it is on in the community edition and off in enterprise.",
env_line("TELEMETRY_ENABLED", "true" if telemetry else "false")
if telemetry is not None
else "# TELEMETRY_ENABLED=false",
]
)
return "\n".join(lines) + "\n"
Expand Down Expand Up @@ -249,7 +252,7 @@ def plan_env(
version: str,
generated_at: datetime,
values: Mapping[str, str],
telemetry: bool = False,
telemetry: bool | None = None,
) -> FileWrite:
"""Create it, append to it, or leave it entirely alone. Never overwrite."""
path = directory / ENV_NAME
Expand Down Expand Up @@ -369,7 +372,7 @@ def plan_writes(
generated_at: datetime,
values: Mapping[str, str],
force: bool = False,
telemetry: bool = False,
telemetry: bool | None = None,
) -> tuple[FileWrite, ...]:
"""The three files, in the order they are reported and applied."""
return (
Expand Down
1 change: 1 addition & 0 deletions cli/src/tripl_cli/install/files/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ x-app-environment: &app-environment
OIDC_AUTO_CREATE_USERS: ${OIDC_AUTO_CREATE_USERS:-}
TELEMETRY_ENABLED: ${TELEMETRY_ENABLED:-}
TELEMETRY_ENDPOINT: ${TELEMETRY_ENDPOINT:-}
DO_NOT_TRACK: ${DO_NOT_TRACK:-}
PUBLIC_DEMO: ${PUBLIC_DEMO:-}
# The Enterprise license key (read by the Enterprise image only).
LICENSE_KEY: ${LICENSE_KEY:-}
Expand Down
24 changes: 24 additions & 0 deletions cli/tests/test_install_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,30 @@ def test_an_explicit_version_gets_no_pin_reminder(
assert "pin a released tag" not in captured.err


@pytest.mark.parametrize(
("extra", "said"),
[
((), True),
(("--no-telemetry",), False),
(("--edition", "enterprise"), False),
(("--edition", "enterprise", "--telemetry"), True),
],
)
def test_a_new_env_says_when_telemetry_is_on_and_how_to_turn_it_off(
install_dir: Path,
fake_runner: FakeRunner,
capsys: pytest.CaptureFixture[str],
extra: tuple[str, ...],
said: bool,
) -> None:
assert main(argv(install_dir, *extra, "--dry-run")) == 0

err = capsys.readouterr().err
assert ("anonymous usage telemetry is on" in err) is said
if said:
assert "TELEMETRY_ENABLED=false" in err


def test_no_start_writes_the_files_and_starts_nothing(
install_dir: Path, fake_runner: FakeRunner, capsys: pytest.CaptureFixture[str]
) -> None:
Expand Down
Loading
Loading