Skip to content
Open
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
51 changes: 48 additions & 3 deletions aider/analytics.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import json
import platform
import re
import sys
import time
import uuid
from pathlib import Path
from urllib.parse import urlsplit, urlunsplit

from mixpanel import MixpanelException
from posthog import Posthog
Expand All @@ -13,6 +15,47 @@
from aider.models import model_info_manager

PERCENT = 10
_ANALYTICS_MAX_TEXT_LENGTH = 500
_URL_RE = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://[^\s<>\"']+")
_ABSOLUTE_PATH_RE = re.compile(
r"(?<![A-Za-z0-9_:/])(?:[A-Za-z]:[\\/]|~[\\/]|/)[^\s<>\"']*"
)
_SENSITIVE_ASSIGNMENT_RE = re.compile(
r"(?i)(\b(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password)\b"
r"\s*[:=]\s*['\"]?)[^'\"\s,}&]+"
)


def _redact_url(match):
value = match.group(0)
trailing = ""
while value and value[-1] in ".,;)]}":
trailing = value[-1] + trailing
value = value[:-1]

try:
parsed = urlsplit(value)
except ValueError:
return "[REDACTED_URL]" + trailing

if parsed.scheme.lower() in {"http", "https"}:
if parsed.query or parsed.fragment:
value = urlunsplit(
(parsed.scheme, parsed.netloc, parsed.path, "REDACTED", "")
)
return value + trailing

return "[REDACTED_URL]" + trailing


def _redact_sensitive_text(value):
"""Remove common secrets and local paths before analytics leaves the process."""
value = _URL_RE.sub(_redact_url, str(value))
value = _SENSITIVE_ASSIGNMENT_RE.sub(r"\1[REDACTED]", value)
value = _ABSOLUTE_PATH_RE.sub("[REDACTED_PATH]", value)
if len(value) > _ANALYTICS_MAX_TEXT_LENGTH:
value = value[:_ANALYTICS_MAX_TEXT_LENGTH] + "..."
return value


def compute_hex_threshold(percent):
Expand Down Expand Up @@ -223,12 +266,12 @@ def event(self, event_name, main_model=None, **kwargs):

properties.update(kwargs)

# Handle numeric values
# Keep numeric metrics numeric, but scrub every free-form value at the boundary.
for key, value in properties.items():
if isinstance(value, (int, float)):
properties[key] = value
else:
properties[key] = str(value)
properties[key] = _redact_sensitive_text(value)

if self.mp:
try:
Expand All @@ -237,7 +280,9 @@ def event(self, event_name, main_model=None, **kwargs):
self.mp = None # Disable mixpanel on connection errors

if self.ph:
self.ph.capture(event_name, distinct_id=self.user_id, properties=dict(properties))
self.ph.capture(
event_name, distinct_id=self.user_id, properties=dict(properties)
)

if self.logfile:
log_entry = {
Expand Down
26 changes: 25 additions & 1 deletion tests/basic/test_analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,31 @@ def test_analytics_event_logging(temp_analytics_file, temp_data_dir):
assert "test_key" in log_entry["properties"]


def test_system_info(temp_data_dir):
def test_event_scrubs_sensitive_text_at_boundary(temp_analytics_file, temp_data_dir):
analytics = Analytics(logfile=temp_analytics_file)
analytics.event(
"test_event",
reason=(
"request https://api.example.test/v1?api_key=not-a-real-key "
"failed at /Users/example/project"
),
model="/private/model",
attempts=3,
ratio=0.5,
label="plain value",
)

with open(temp_analytics_file) as f:
properties = json.loads(f.read().strip())["properties"]

assert "not-a-real-key" not in properties["reason"]
assert "/Users/example/project" not in properties["reason"]
assert "https://api.example.test/v1" in properties["reason"]
assert properties["model"] == "[REDACTED_PATH]"
assert properties["attempts"] == 3
assert properties["ratio"] == 0.5
assert properties["label"] == "plain value"

analytics = Analytics()
sys_info = analytics.get_system_info()

Expand Down