Skip to content

Commit ad3b0e2

Browse files
kevinmessiaencursoragenthenchaves
authored
fix(telemetry): honor opt-out env vars without contacting PostHog πŸ€–πŸ€–πŸ€–πŸ€– (#2787)
* fix(telemetry): honor opt-out env vars without contacting PostHog Re-read GISKARD_TELEMETRY_DISABLED and DO_NOT_TRACK on capture (including cwd .env and quoted values), and pass send=False so a firewalled process never opens eu.i.posthog.com after opt-out. Co-authored-by: Kevin Messiaen <kevinmessiaen@users.noreply.github.com> * simplify code * add more tests --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Kevin Messiaen <kevinmessiaen@users.noreply.github.com> Co-authored-by: Henrique Chaves <44180294+henchaves@users.noreply.github.com> Co-authored-by: Henrique Chaves <henrique@giskard.ai>
1 parent 5ec92a6 commit ad3b0e2

4 files changed

Lines changed: 406 additions & 9 deletions

File tree

β€ŽREADME.mdβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ Requires Python 3.12+.
4444
| `openai` / `anthropic` / … | provider SDKs (see `pyproject.toml` optional deps) |
4545

4646
**Telemetry:** optional aggregated analytics via `giskard-core`. No prompts or outputs are sent.
47-
Opt out **before importing Giskard**: `export DO_NOT_TRACK=1` or `export GISKARD_TELEMETRY_DISABLED=1`.
47+
Opt out with `export DO_NOT_TRACK=1` or `export GISKARD_TELEMETRY_DISABLED=1` (or the same keys in a `.env` file in the working directory). Set them before import to skip creating `~/.giskard/id`; setting them later still stops further sends.
4848
Details: [`giskard-core` README](libs/giskard-core/README.md#telemetry).
4949

5050
---

β€Žlibs/giskard-core/README.mdβ€Ž

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,13 @@ A random **persistent ID** may be stored under `~/.giskard/id` so repeated sessi
3131

3232
### How to disable telemetry
3333

34-
Set any of these environment variables to a truthy value **before** importing `giskard` packages (values are matched case-insensitively; common examples: `1`, `true`, `yes`, `on`):
34+
Set any of these environment variables to a truthy value (matched case-insensitively; wrapping quotes are ignored; common examples: `1`, `true`, `yes`, `on`):
3535

3636
- `DO_NOT_TRACK`
3737
- `GISKARD_TELEMETRY_DISABLED`
3838

39+
Flags are read from the **process environment** and from a **`.env` file in the working directory** (process env wins). Set them **before importing** Giskard packages to skip creating `~/.giskard/id` and to never start the analytics sender. If you set them later, further events are dropped and the sender is stopped so **no requests are made** to PostHog (including in environments that block `eu.i.posthog.com`).
40+
3941
You can also call `disable_telemetry()` from `giskard.core` at runtime to turn off further sends for that process (for example from test harnesses). That does not remove `~/.giskard/id` if it was already created; use env-based opt-out before import to avoid writing the file.
4042

4143
```python
@@ -48,7 +50,7 @@ disable_telemetry()
4850

4951
When telemetry is **enabled**, PostHog may apply **GeoIP** enrichment to events (server-side location metadata used in dashboards). That enrichment is **off** whenever telemetry is fully disabled (see above) or when you call `disable_telemetry()`.
5052

51-
To **keep usage analytics** but **disable GeoIP only**, set this environment variable to a truthy value **before** importing Giskard packages (same matching rules as in [How to disable telemetry](#how-to-disable-telemetry)):
53+
To **keep usage analytics** but **disable GeoIP only**, set this environment variable to a truthy value (same matching rules and `.env` behavior as in [How to disable telemetry](#how-to-disable-telemetry)):
5254

5355
- `GISKARD_TELEMETRY_DISABLE_GEOIP`
5456

β€Žlibs/giskard-core/src/giskard/core/telemetry/telemetry.pyβ€Ž

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import asyncio
2+
import atexit
23
import contextvars
34
import functools
45
import os
6+
import re
57
import sys
68
import uuid
79
from collections.abc import Callable, Iterator
@@ -20,15 +22,42 @@
2022
_DISABLE_GEOIP_ENV_VARS = [
2123
"GISKARD_TELEMETRY_DISABLE_GEOIP",
2224
]
25+
_OPT_OUT_ENV_KEYS = frozenset((*_DISABLING_ENV_VARS, *_DISABLE_GEOIP_ENV_VARS))
26+
27+
28+
def _dotenv_flags() -> dict[str, str]:
29+
"""Opt-out flags from a cwd ``.env``, the file giskard.checks settings already read.
30+
31+
Minimal dotenv rules: ``export`` prefixes and unquoted `` # comment`` tails
32+
are dropped; ``errors="replace"`` so a non-UTF-8 file cannot abort import.
33+
"""
34+
try:
35+
text = Path(".env").read_text(encoding="utf-8", errors="replace")
36+
except OSError:
37+
return {}
38+
flags: dict[str, str] = {}
39+
for line in text.lstrip("\ufeff").splitlines():
40+
key, sep, value = line.strip().removeprefix("export ").partition("=")
41+
if sep and key.strip() in _OPT_OUT_ENV_KEYS:
42+
flags[key.strip()] = re.sub(r"\s+#.*", "", value).strip()
43+
return flags
44+
45+
46+
def _flag_is_true(name: str) -> bool:
47+
"""True if ``name`` is truthy in the process env, else in cwd ``.env``."""
48+
value = os.environ.get(name)
49+
if value is None:
50+
value = _dotenv_flags().get(name)
51+
return value is not None and is_true_env_str(value.strip().strip("'\""))
2352

2453

2554
def _should_disable() -> bool:
26-
return any(is_true_env_str(os.getenv(var)) for var in _DISABLING_ENV_VARS)
55+
return any(_flag_is_true(var) for var in _DISABLING_ENV_VARS)
2756

2857

2958
def _should_disable_geoip() -> bool:
3059
return _should_disable() or any(
31-
is_true_env_str(os.getenv(var)) for var in _DISABLE_GEOIP_ENV_VARS
60+
_flag_is_true(var) for var in _DISABLE_GEOIP_ENV_VARS
3261
)
3362

3463

@@ -118,20 +147,40 @@ def _get_or_create_anonymous_id() -> str | None:
118147
# dashboards, while _anonymous_id keeps them all linked to the same user.
119148
_process_session_id = str(uuid.uuid4())
120149

150+
# send=False when opted out: no consumer thread, no atexit join, no HTTP,
151+
# so a firewalled ``eu.i.posthog.com`` is never contacted.
152+
_disabled_at_import = _should_disable()
121153
telemetry = Posthog(
122154
project_api_key="phc_Asp36pe4X5WMqeJ4aMMV4gq5LGdGw69mdYSdEYGpbxm2", # pragma: allowlist secret
123155
host="https://eu.i.posthog.com",
124-
disabled=_should_disable(),
156+
disabled=_disabled_at_import,
125157
disable_geoip=_should_disable_geoip(),
158+
send=not _disabled_at_import,
126159
)
127160

128161

129162
def disable_telemetry() -> None:
130-
"""
131-
Disable telemetry. Overrides the environment variable settings.
163+
"""Disable telemetry for this process, overriding the environment variables.
164+
165+
One-way. Stops the PostHog sender so no further requests reach the
166+
analytics host. Does not remove ``~/.giskard/id`` if it was already created.
132167
"""
133168
telemetry.disabled = True
134169
telemetry.disable_geoip = True
170+
telemetry.send = False
171+
for consumer in telemetry.consumers or []:
172+
consumer.pause()
173+
# Consumers are daemon threads; with the atexit join unregistered they
174+
# cannot delay exit on uploads to a blocked host.
175+
atexit.unregister(telemetry.join)
176+
177+
178+
def _apply_env_opt_out() -> None:
179+
"""Honor opt-out flags set after import (notebooks, ``.env``); one-way."""
180+
if _should_disable():
181+
disable_telemetry()
182+
elif _should_disable_geoip():
183+
telemetry.disable_geoip = True
135184

136185

137186
# Tracks whether we are currently inside any telemetry scope.
@@ -160,7 +209,8 @@ def telemetry_capture(
160209
properties : dict[str, object] or None
161210
Optional event properties, passed through to PostHog unchanged.
162211
"""
163-
if not _in_telemetry_scope.get():
212+
_apply_env_opt_out()
213+
if telemetry.disabled or not _in_telemetry_scope.get():
164214
return
165215
_ = telemetry.capture(event, properties=properties)
166216

@@ -176,6 +226,7 @@ def telemetry_run_context() -> Iterator[None]:
176226
is_outermost = not _in_telemetry_scope.get()
177227
token = _in_telemetry_scope.set(True)
178228
try:
229+
_apply_env_opt_out()
179230
with telemetry.new_context(capture_exceptions=False):
180231
if _anonymous_id is not None:
181232
identify_context(_anonymous_id)

0 commit comments

Comments
Β (0)