Skip to content

Commit e66df70

Browse files
Merge pull request #149 from datasciencemonkey/feat/telemetry-tracking
feat: add control plane event tracking
2 parents fc72660 + 011453d commit e66df70

6 files changed

Lines changed: 428 additions & 1 deletion

File tree

app.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import app_state
2424
from utils import ensure_https, get_gateway_host
2525
from pat_rotator import PATRotator
26+
from telemetry import log_telemetry, set_product_info
2627

2728
# Sanitize DATABRICKS_TOKEN early — the platform sometimes injects trailing
2829
# newlines / whitespace which causes auth failures. Cleaning it here prevents
@@ -175,6 +176,7 @@ def _setup_git_config():
175176
db_token = os.environ.get("DATABRICKS_TOKEN")
176177
if db_host and db_token:
177178
w = WorkspaceClient(host=db_host, token=db_token, auth_type="pat")
179+
set_product_info(w)
178180
me = w.current_user.me()
179181
user_email = me.user_name
180182
display_name = me.display_name or user_email.split("@")[0]
@@ -412,6 +414,7 @@ def get_token_owner():
412414
if app_name:
413415
try:
414416
w = WorkspaceClient() # auto-detects SP credentials
417+
set_product_info(w)
415418
app = w.apps.get(name=app_name)
416419
owner = (app.creator or "").lower()
417420
logger.info(f"Owner resolved from app.creator: {owner}")
@@ -426,6 +429,7 @@ def get_token_owner():
426429
if not host or not token:
427430
return None
428431
w = WorkspaceClient(host=host, token=token, auth_type="pat")
432+
set_product_info(w)
429433
username = w.current_user.me().user_name
430434
return username.lower() if username else username
431435
except Exception as e:
@@ -1060,6 +1064,9 @@ def create_session():
10601064
thread = threading.Thread(target=read_pty_output, args=(session_id, master_fd), daemon=True)
10611065
thread.start()
10621066

1067+
# Telemetry: track session creation with agent type
1068+
log_telemetry("agent", label or "shell")
1069+
10631070
return jsonify({"session_id": session_id})
10641071
except Exception as e:
10651072
return jsonify({"error": str(e)}), 500
@@ -1112,6 +1119,10 @@ def upload_file():
11121119

11131120
file_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0
11141121
logger.info(f"Upload saved: {file_path} ({file_size} bytes)")
1122+
1123+
# Telemetry: track file uploads
1124+
log_telemetry("event", "file_upload")
1125+
11151126
return jsonify({"path": file_path})
11161127

11171128

@@ -1269,6 +1280,9 @@ def initialize_app(local_dev=False):
12691280
os.environ.pop("DATABRICKS_CLIENT_SECRET", None)
12701281
logger.info("SP credentials stripped — PAT-only auth from this point")
12711282

1283+
# Telemetry: app startup ping (fire-and-forget in background thread)
1284+
log_telemetry("event", "app_startup")
1285+
12721286
# Start background cleanup thread
12731287
cleanup_thread = threading.Thread(target=cleanup_stale_sessions, daemon=True)
12741288
cleanup_thread.start()

pat_rotator.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,13 @@ def _rotate_once(self):
159159
logger.info(f"INFO: PAT rotation complete — new token (id={new_token_id}, "
160160
f"expires in {self._token_lifetime}s). First rotation — no old token to revoke.")
161161

162+
# Telemetry: track PAT rotation events (import here to avoid circular deps)
163+
try:
164+
from telemetry import log_telemetry
165+
log_telemetry("event", "pat_rotation")
166+
except Exception:
167+
pass # Telemetry must never break rotation
168+
162169
return True
163170

164171
def revoke_bootstrap_token(self):

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "coda"
3-
version = "0.17.2"
3+
version = "0.17.3"
44
description = "CoDA - Coding Agents on Databricks Apps"
55
requires-python = ">=3.10"
66
dependencies = [

sync_to_workspace.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ def get_user_email():
3535
if not host or not token:
3636
raise RuntimeError("~/.databrickscfg missing host or token")
3737
w = WorkspaceClient(host=host, token=token, auth_type="pat")
38+
try:
39+
from telemetry import set_product_info
40+
set_product_info(w)
41+
except Exception:
42+
pass
3843
return w.current_user.me().user_name
3944

4045

@@ -68,6 +73,12 @@ def sync_project(project_path: Path):
6873

6974
if result.returncode == 0:
7075
print(f"✓ Synced to {workspace_dest}")
76+
# Telemetry: track workspace sync events
77+
try:
78+
from telemetry import log_telemetry
79+
log_telemetry("event", "workspace_sync")
80+
except Exception:
81+
pass # Telemetry must never break sync
7182
else:
7283
print(f"⚠ Sync warning: {result.stderr}", file=sys.stderr)
7384

telemetry.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""Databricks Labs telemetry for CoDA.
2+
3+
Follows the DQX pattern: piggybacks telemetry on the Databricks SDK's
4+
User-Agent header. Each log_telemetry() call creates a throwaway
5+
WorkspaceClient, augments the User-Agent with key-value data, and fires
6+
clusters.select_spark_version() to transmit the header to Databricks
7+
servers where it's recorded.
8+
9+
All telemetry runs in background daemon threads -- never blocks the
10+
Flask request path or terminal I/O.
11+
12+
Reference: https://github.com/databrickslabs/dqx/blob/main/src/databricks/labs/dqx/telemetry.py
13+
"""
14+
15+
import functools
16+
import logging
17+
import os
18+
import threading
19+
20+
import tomllib
21+
22+
logger = logging.getLogger(__name__)
23+
24+
_version_cache = None
25+
26+
27+
def _get_version():
28+
"""Get CoDA version from pyproject.toml (cached after first call)."""
29+
global _version_cache
30+
if _version_cache is not None:
31+
return _version_cache
32+
try:
33+
pyproject = os.path.join(os.path.dirname(__file__), "pyproject.toml")
34+
with open(pyproject, "rb") as f:
35+
_version_cache = tomllib.load(f)["project"]["version"]
36+
except Exception:
37+
_version_cache = "0.0.0"
38+
return _version_cache
39+
40+
41+
def set_product_info(ws):
42+
"""Set CoDA product info on a WorkspaceClient for telemetry attribution.
43+
44+
Call this on any WorkspaceClient so all SDK API calls carry the 'coda'
45+
product identifier in the User-Agent header.
46+
"""
47+
product_info = getattr(ws.config, "_product_info", None)
48+
if product_info is None or product_info[0] != "coda":
49+
setattr(ws.config, "_product_info", ("coda", _get_version()))
50+
51+
52+
def log_telemetry(key, value):
53+
"""Send a telemetry key-value pair via the Databricks SDK User-Agent header.
54+
55+
Creates a throwaway WorkspaceClient from ~/.databrickscfg, adds the
56+
key-value to the User-Agent, and fires clusters.select_spark_version()
57+
to transmit. Runs in a background daemon thread. Errors are caught and
58+
logged, never raised.
59+
"""
60+
61+
def _send():
62+
try:
63+
from databricks.sdk import WorkspaceClient
64+
from databricks.sdk.errors import DatabricksError
65+
66+
ws = WorkspaceClient()
67+
set_product_info(ws)
68+
new_config = ws.config.copy().with_user_agent_extra(key, value)
69+
temp_ws = WorkspaceClient(config=new_config)
70+
try:
71+
temp_ws.clusters.select_spark_version()
72+
except DatabricksError as e:
73+
logger.debug(f"Telemetry transmit failed: {e}")
74+
except Exception as e:
75+
logger.debug(f"Telemetry error ({key}={value}): {e}")
76+
77+
threading.Thread(target=_send, daemon=True, name=f"telemetry-{key}").start()
78+
79+
80+
def telemetry_logger(key, value):
81+
"""Decorator that fires telemetry before executing the wrapped function.
82+
83+
Works on standalone functions and class methods alike. Creates its own
84+
WorkspaceClient from ~/.databrickscfg -- no self.ws required.
85+
"""
86+
87+
def decorator(func):
88+
@functools.wraps(func)
89+
def wrapper(*args, **kwargs):
90+
try:
91+
log_telemetry(key, value)
92+
except Exception:
93+
pass # Telemetry must never break the wrapped function
94+
return func(*args, **kwargs)
95+
96+
return wrapper
97+
98+
return decorator

0 commit comments

Comments
 (0)