mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
Each of these was checked against the live database before it moved,
because a member name is free to rename and a stored value is not.
Members only -- the value never said hog, so nothing in Postgres changes:
TaggerType.HOG / EvaluationType.HOG the column already holds `script`;
`HOG = "script", "Script"` was a
member disagreeing with its own value
FEATURE_FLAGS.HOG the flag key `script` is read by the
flag service and does not move
HEDGEFN_SKIN_SPIDERHOG same, key `mascot-skin-spiderscript`
_HOGLAND_MARKER already `/var/lib/script`; renamed to
_BOX_MARKER, which is what the
function returns and what the comment
beside it already called it
Value too, having established nothing stores it: `InsightType.HOG = 'HOG'`
is derived from `query.kind === 'ScriptQuery'`, never written -- the one
writer, queryNodeToFilter, has no ScriptQuery entry, and zero of the 28
insight rows carry it in `filters.insight`.
Two readers keyed on that value had to move with it, and neither would
have been caught by renaming the enum alone: INSIGHT_TYPE_URLS spells
`HOG:` as a plain key while its siblings are computed, and the Slack
unfurl map keys the same string in Python.
The rest is copy a person reads -- "HOG code exceeds maximum size" is an
API error message, not an identifier.
Left alone deliberately: `ReviewHog` and `HogDesk`, which survive only in
applied migrations' `choices=` lists. The live members are already
`REVIEW = "review"` and `Desk = "desk"`, and Django compares choices in
Python without ever writing them, so those lists are a snapshot of a
field that has not looked like that for some time.
Co-authored-by: Hanzo Dev <[email protected]>
212 lines
7.1 KiB
Python
212 lines
7.1 KiB
Python
"""Insights-specific telemetry property hooks.
|
|
|
|
Registers a hook that enriches every ``command_started`` / ``command_completed``
|
|
event with Insights-developer context (process manager, devenv state, org
|
|
membership, etc.). Imports here are stdlib only — keep it light, this loads at
|
|
boot.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
import time
|
|
import functools
|
|
import subprocess
|
|
import configparser
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from insightscli.hooks import register_telemetry_properties
|
|
from insightscli.manifest import REPO_ROOT
|
|
from insightscli.telemetry import _load_config, _save_config, is_ci
|
|
|
|
_INSIGHTS_DEV_CACHE_TTL_SECONDS = 30 * 86400 # 30 days
|
|
|
|
|
|
# Created by box's guest overlay (script-env-materialise) on every box
|
|
# boot; the guest exposes no ambient env var, so this path is the marker.
|
|
# Prefer baking INSIGHTSCLI_ENVIRONMENT=box into the guest image -- this is the
|
|
# fallback until that ships.
|
|
_BOX_MARKER = Path("/var/lib/script")
|
|
|
|
# Coder sets these inside every workspace, which is what our devboxes run on.
|
|
_DEVBOX_ENV_MARKERS = ("CODER", "CODER_WORKSPACE_NAME")
|
|
|
|
# Ambient markers set by agent harnesses in the shells they spawn. Ordered
|
|
# most-specific first: insights-code drives claude/codex under the hood, so its
|
|
# marker must win over theirs. Harnesses without an ambient marker (e.g.
|
|
# non-sandboxed codex) can self-declare via INSIGHTSCLI_AGENT instead.
|
|
_AGENT_ENV_MARKERS = (
|
|
("INSIGHTS_CODE_VERSION", "insights-code"),
|
|
("CLAUDECODE", "claude-code"),
|
|
("CODEX_SANDBOX", "codex"),
|
|
)
|
|
|
|
|
|
def _declared(var: str) -> str:
|
|
"""Self-declared label from *var*, normalized so a sloppy export ('Sandbox ')
|
|
can't fragment the telemetry dimension."""
|
|
return os.environ.get(var, "").strip().lower()[:64]
|
|
|
|
|
|
def _detect_environment() -> str:
|
|
"""Classify where insightscli is running: ci, devbox, box, local, or a self-declared value.
|
|
|
|
Environments without an ambient marker (e.g. agent sandboxes) should export
|
|
``INSIGHTSCLI_ENVIRONMENT`` in their bootstrap to self-declare.
|
|
"""
|
|
declared = _declared("INSIGHTSCLI_ENVIRONMENT")
|
|
if declared:
|
|
return declared
|
|
# Unreachable while the CI gate disables telemetry entirely; kept so the
|
|
# label can never diverge from the gate (same rationale as the is_ci prop).
|
|
if is_ci():
|
|
return "ci"
|
|
# Checked before the box marker so Coder-on-box reads as a devbox.
|
|
if any(os.environ.get(var) for var in _DEVBOX_ENV_MARKERS):
|
|
return "devbox"
|
|
if _BOX_MARKER.exists():
|
|
return "box"
|
|
return "local"
|
|
|
|
|
|
def _detect_agent() -> str | None:
|
|
"""Name of the agent harness driving this invocation, or None for a human."""
|
|
declared = _declared("INSIGHTSCLI_AGENT")
|
|
if declared:
|
|
return declared
|
|
for var, agent in _AGENT_ENV_MARKERS:
|
|
if os.environ.get(var):
|
|
return agent
|
|
return None
|
|
|
|
|
|
@functools.cache
|
|
def _repo_commit() -> tuple[str, str] | None:
|
|
"""Short SHA and committer date of HEAD, identifying the checkout's vintage.
|
|
|
|
--no-show-signature keeps gpg verification lines (log.showSignature=true)
|
|
out of stdout, which would otherwise corrupt the parse.
|
|
"""
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "-C", str(REPO_ROOT), "log", "-1", "--no-show-signature", "--format=%h %cI"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=2,
|
|
)
|
|
if result.returncode != 0:
|
|
return None
|
|
sha, _, commit_date = result.stdout.strip().partition(" ")
|
|
if not sha or not commit_date:
|
|
return None
|
|
return sha, commit_date
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _repo_commit_properties() -> dict[str, str]:
|
|
commit = _repo_commit()
|
|
if commit is None:
|
|
return {}
|
|
return {"repo_sha": commit[0], "repo_commit_date": commit[1]}
|
|
|
|
|
|
@functools.cache
|
|
def _git_branch() -> str | None:
|
|
"""Current branch name — the join key from a insightscli run back to its PR.
|
|
|
|
"HEAD" (detached) and failures collapse to None so they don't pollute the
|
|
dimension. Cached per process: insightscli commands are short-lived, so the branch
|
|
can't change mid-run.
|
|
"""
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "-C", str(REPO_ROOT), "rev-parse", "--abbrev-ref", "HEAD"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=2,
|
|
)
|
|
except Exception:
|
|
return None
|
|
branch = result.stdout.strip()
|
|
return branch if result.returncode == 0 and branch and branch != "HEAD" else None
|
|
|
|
|
|
def _infer_process_manager(command: str | None) -> str | None:
|
|
pm = os.environ.get("INSIGHTSCLI_PROCESS_MANAGER")
|
|
if pm:
|
|
return os.path.basename(pm)
|
|
if command == "start":
|
|
return "mprocs" if "--mprocs" in sys.argv[2:] else "phrocs"
|
|
return None
|
|
|
|
|
|
def _check_email_domain() -> bool:
|
|
"""Fallback signal: git ``user.email`` ends with ``@hanzo.ai``."""
|
|
parser = configparser.RawConfigParser()
|
|
try:
|
|
parser.read([Path.home() / ".gitconfig", REPO_ROOT / ".git" / "config"])
|
|
email = parser.get("user", "email", fallback="")
|
|
return email.endswith("@hanzo.ai")
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _check_github_org_membership() -> bool | None:
|
|
"""Use ``gh api`` to check Insights org membership. Returns None if gh is unavailable."""
|
|
try:
|
|
result = subprocess.run(
|
|
["gh", "api", "/user/memberships/orgs/Insights", "--silent"],
|
|
capture_output=True,
|
|
timeout=5,
|
|
)
|
|
return result.returncode == 0
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _is_insights_dev() -> bool:
|
|
"""Return True if the current user is a Insights GitHub org member.
|
|
|
|
Caches the boolean for 30 days in the telemetry config. Falls back to a
|
|
git-email-domain check when ``gh`` is unavailable or unauthenticated.
|
|
"""
|
|
config = _load_config()
|
|
cached = config.get("is_insights_org_member")
|
|
checked_at = config.get("org_check_timestamp", 0.0)
|
|
|
|
if cached is not None and (time.time() - checked_at) < _INSIGHTS_DEV_CACHE_TTL_SECONDS:
|
|
return cached
|
|
|
|
is_member = _check_github_org_membership()
|
|
if is_member is None:
|
|
is_member = _check_email_domain()
|
|
|
|
config["is_insights_org_member"] = is_member
|
|
config["org_check_timestamp"] = time.time()
|
|
_save_config(config)
|
|
return is_member
|
|
|
|
|
|
def _insights_telemetry_properties(command: str | None = None) -> dict[str, Any]:
|
|
# Agent-driven traffic (e.g. skills running metabase:query) otherwise
|
|
# swamps human usage stats.
|
|
agent = _detect_agent()
|
|
return {
|
|
"agent": agent,
|
|
"environment": _detect_environment(),
|
|
"has_devenv_config": (REPO_ROOT / ".insights" / ".generated" / "mprocs.yaml").exists(),
|
|
"in_flox": os.environ.get("FLOX_ENV") is not None,
|
|
"is_agent": agent is not None,
|
|
"is_worktree": (REPO_ROOT / ".git").is_file(),
|
|
"is_insights_dev": _is_insights_dev(),
|
|
"process_manager": _infer_process_manager(command),
|
|
"git_branch": _git_branch(),
|
|
**_repo_commit_properties(),
|
|
}
|
|
|
|
|
|
register_telemetry_properties(_insights_telemetry_properties)
|