diff --git a/README.md b/README.md index bd33749..ffb5555 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,23 @@ When not running in debug mode, the hook writes logs to `/tmp/1password-cursor-h └── README.md ``` +## Telemetry + +The plugin emits **opt-in** telemetry so 1Password can understand plugin adoption and the prevalence of common failure modes (missing files, disabled mounts). Two event types are emitted: + +- `agent_hook_execution` — fired once per hook invocation; carries the hook name, plugin version, client (`cursor`), bucketed duration, decision (`allow`/`deny`), reason for deny, validation mode (`default`/`configured`), and a count of mounts checked. +- `agent_hook_install` — fired once per `(hook_name, plugin_version)` on the first hook run after installation or upgrade; `install_method` is `plugin_marketplace`. + +**Opt-in only.** Events are written only when the file `~/.config/1Password/telemetry-enabled` exists. The 1Password desktop app creates and removes this file based on your in-app telemetry preference (Settings → Manage Account → Data Usage). If the app has never run, or all accounts have opted out, no events are written. + +**No PII.** Events contain hook name and version, client, decision, bucketed duration, mode, mount count, and a deny reason. No paths, file contents, environment names, or workspace paths are recorded. + +**Fail-open.** Telemetry runs in a detached background subshell after the hook has returned its decision to Cursor. Any failure (missing helpers, disk full, permission denied) is silently swallowed — telemetry can never affect a hook decision. + +**Where events are written.** Events are appended as JSON lines to `~/.config/1Password/data/hook-events/events.jsonl`. The 1Password desktop app periodically ingests this file and forwards events to 1Password's telemetry pipeline. Telemetry only fires on macOS and Linux; the Windows stub does not emit events. + +**To disable.** Open the 1Password desktop app → Settings → Manage Account → Data Usage and turn off product telemetry. + ## Resources - [Validate local `.env` files with Cursor Agent](https://developer.1password.com/docs/environments/cursor-hook-validate/) — full setup guide on the 1Password Developer site diff --git a/scripts/lib/telemetry.sh b/scripts/lib/telemetry.sh new file mode 100644 index 0000000..af68d9a --- /dev/null +++ b/scripts/lib/telemetry.sh @@ -0,0 +1,191 @@ +# Shared telemetry utilities for the 1Password Cursor plugin. +# Source this file; it defines functions only and has no side effects. +# +# Writes JSONL telemetry events to disk for the 1Password app to ingest. +# All functions fail silently — telemetry must never affect hook decisions. +# +# The host script (validate-mounted-env-files) is expected to provide its own +# `log` and `escape_json_string` functions — these helpers rely on late +# binding rather than re-defining them. + +[[ -n "${_LIB_TELEMETRY_LOADED:-}" ]] && return 0 +_LIB_TELEMETRY_LOADED=1 + +# Convert raw milliseconds to a bucketed range string. +bucket_duration_ms() { + local ms="${1:-0}" + if [[ "$ms" -lt 50 ]]; then echo "ms_0_to_50" + elif [[ "$ms" -lt 100 ]]; then echo "ms_50_to_100" + elif [[ "$ms" -lt 200 ]]; then echo "ms_100_to_200" + elif [[ "$ms" -lt 500 ]]; then echo "ms_200_to_500" + elif [[ "$ms" -lt 1000 ]]; then echo "ms_500_to_1000" + elif [[ "$ms" -lt 5000 ]]; then echo "ms_1000_to_5000" + else echo "ms_5000_plus" + fi +} + +current_time_ms() { + local now seconds fraction + + if [[ "${EPOCHREALTIME:-}" =~ ^[0-9]+\.[0-9]+$ ]]; then + seconds="${EPOCHREALTIME%.*}" + fraction="${EPOCHREALTIME#*.}000" + echo "${seconds}${fraction:0:3}" + return 0 + fi + + now=$(date +%s%3N 2>/dev/null || true) + if [[ "$now" =~ ^[0-9]+$ ]]; then + echo "$now" + return 0 + fi + + now=$(perl -MTime::HiRes=time -e 'printf "%.0f\n", time() * 1000' 2>/dev/null || true) + if [[ "$now" =~ ^[0-9]+$ ]]; then + echo "$now" + return 0 + fi + + now=$(python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null || true) + if [[ "$now" =~ ^[0-9]+$ ]]; then + echo "$now" + return 0 + fi + + echo "$(($(date +%s) * 1000))" +} + +get_telemetry_dir() { + echo "${HOME}/.config/1Password/data/hook-events" +} + +# Check whether the 1Password app has signaled that telemetry is enabled. +# Returns 0 (true) if the signal file exists, 1 (false) otherwise. +telemetry_consent_enabled() { + [[ -f "${HOME}/.config/1Password/telemetry-enabled" ]] +} + +# Append a single JSON line to the events.jsonl file. +# Checks consent and enforces a 1MB file size cap. +write_telemetry_event() { + local json_line="$1" + local event_dir + event_dir=$(get_telemetry_dir) + + if ! telemetry_consent_enabled; then + return 0 + fi + + mkdir -p "$event_dir" 2>/dev/null || return 0 + + local event_file="${event_dir}/events.jsonl" + + # 1MB file size cap + if [[ -f "$event_file" ]]; then + local file_size + file_size=$(stat -f%z "$event_file" 2>/dev/null || stat -c%s "$event_file" 2>/dev/null || echo "0") + if [[ "$file_size" -gt 1048576 ]]; then + log "Telemetry file exceeds 1MB, skipping write" + return 0 + fi + fi + + printf '%s\n' "$json_line" >> "$event_file" 2>/dev/null || true +} + +# Write an agent_hook_execution telemetry event. +# `mode` and `mount_count` are hook-specific and may be empty for hooks that +# do not have a meaningful value to populate them; in that case they are +# serialized as JSON null per the schema. +write_execution_event() { + local hook_name="$1" + local hook_version="$2" + local client="$3" + local event_type="$4" + local decision="$5" + local deny_reason="$6" + local duration_ms="$7" + local mode="$8" + local mount_count="$9" + + local escaped_hook_name escaped_hook_version escaped_client escaped_event_type + escaped_hook_name=$(escape_json_string "$hook_name") + escaped_hook_version=$(escape_json_string "$hook_version") + escaped_client=$(escape_json_string "$client") + escaped_event_type=$(escape_json_string "$event_type") + + local deny_reason_json + if [[ -z "$deny_reason" ]]; then + deny_reason_json="null" + else + local escaped_deny_reason + escaped_deny_reason=$(escape_json_string "$deny_reason") + deny_reason_json="\"${escaped_deny_reason}\"" + fi + + local mode_json + if [[ -z "$mode" ]]; then + mode_json="null" + else + local escaped_mode + escaped_mode=$(escape_json_string "$mode") + mode_json="\"${escaped_mode}\"" + fi + + local mount_count_json + if [[ -z "$mount_count" ]]; then + mount_count_json="null" + else + mount_count_json="$mount_count" + fi + + local duration_bucket + duration_bucket=$(bucket_duration_ms "$duration_ms") + + local json_line + json_line="{\"schema\":\"agent_hook_execution\",\"hook_name\":\"${escaped_hook_name}\",\"hook_version\":\"${escaped_hook_version}\",\"client\":\"${escaped_client}\",\"event_type\":\"${escaped_event_type}\",\"decision\":\"${decision}\",\"deny_reason\":${deny_reason_json},\"duration_bucket\":\"${duration_bucket}\",\"mode\":${mode_json},\"mount_count\":${mount_count_json}}" + + write_telemetry_event "$json_line" +} + +# Write an agent_hook_install telemetry event. +write_install_event() { + local client="$1" + local hook_name="$2" + local hook_version="$3" + local install_method="$4" + + local escaped_client escaped_hook_name escaped_hook_version + escaped_client=$(escape_json_string "$client") + escaped_hook_name=$(escape_json_string "$hook_name") + escaped_hook_version=$(escape_json_string "$hook_version") + + local json_line + json_line="{\"schema\":\"agent_hook_install\",\"client\":\"${escaped_client}\",\"hook_name\":\"${escaped_hook_name}\",\"hook_version\":\"${escaped_hook_version}\",\"install_method\":\"${install_method}\"}" + + write_telemetry_event "$json_line" +} + +# Emit an `agent_hook_install` event with install_method=plugin_marketplace +# exactly once per (client, hook_name, hook_version). The plugin marketplace +# does not expose a lifecycle hook we can listen for, so we detect the install +# by sentinel on first hook execution after installation/upgrade. +emit_plugin_marketplace_install_event_once() { + local client="$1" + local hook_name="$2" + local hook_version="$3" + local event_dir + event_dir=$(get_telemetry_dir) + + if ! telemetry_consent_enabled; then + return 0 + fi + + mkdir -p "$event_dir" 2>/dev/null || return 0 + + local sentinel="${event_dir}/.installed-${client}-${hook_name}-${hook_version}-plugin_marketplace" + if [[ ! -f "$sentinel" ]]; then + write_install_event "$client" "$hook_name" "$hook_version" "plugin_marketplace" + touch "$sentinel" 2>/dev/null || true + fi +} diff --git a/scripts/validate-mounted-env-files b/scripts/validate-mounted-env-files index 008b025..fdaebaa 100755 --- a/scripts/validate-mounted-env-files +++ b/scripts/validate-mounted-env-files @@ -14,6 +14,7 @@ set -euo pipefail # - TOML Parsing Functions # - Main Execution Logic (early platform exit must run before stdin is read) # - Permission Decision Logic +# - Telemetry (opt-in; off by default; gated on a signal file from OPH) # # ============================================================================ @@ -38,6 +39,22 @@ permission="allow" # The message for the agent to interpret if the permission is denied. agent_message="" +# Telemetry: which validation mode was used (set when entering the branch). +resolved_mode="" +# Telemetry: number of workspace-relevant mounts checked during this run. +total_mount_count=0 +# Telemetry: derived deny reason for the agent_hook_execution event. +deny_reason="" + +# Source the shared telemetry helpers. Fail-open if missing (telemetry is +# optional; the hook must keep working even if the lib is absent). +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/telemetry.sh +source "${SCRIPT_DIR}/lib/telemetry.sh" 2>/dev/null || true + +# Record start time for hook duration measurement. +start_ms=$(current_time_ms 2>/dev/null || echo "0") + # ============================================================================ # CORE UTILITY FUNCTIONS # ============================================================================ @@ -135,6 +152,69 @@ EOF fi } +# Derive duration + deny_reason and write the telemetry events in a detached +# background subshell, then return immediately. Safe to call from any exit path +# (it fails open if the telemetry helpers are unavailable), and it must be +# called exactly once per invocation so events map 1:1 to hook executions. +emit_telemetry_async() { + local end_ms duration_ms + end_ms=$(current_time_ms 2>/dev/null || echo "$start_ms") + duration_ms=$((end_ms - start_ms)) + + # Three-bucket deny_reason: the "both" case maps to its own value so the + # dashboard can distinguish "configuration drift" (some mounts missing on + # disk, others toggled off in 1Password) from each pure case. + if [[ "$permission" == "deny" ]]; then + if [[ ${#all_missing_invalid[@]} -gt 0 ]] && [[ ${#disabled_mounts[@]} -gt 0 ]]; then + deny_reason="file_missing_and_disabled" + elif [[ ${#all_missing_invalid[@]} -gt 0 ]]; then + deny_reason="file_missing" + elif [[ ${#disabled_mounts[@]} -gt 0 ]]; then + deny_reason="file_disabled" + fi + fi + + # Detached background subshell so the script exits as soon as the response + # has been written to Cursor. The `>/dev/null 2>&1` redirect is load-bearing: + # without it, the subshell inherits the script's stdout pipe to Cursor, which + # would defeat the backgrounding (Cursor's `read(stdout)` waits for EOF). + ( + # Resolve the hook version from the plugin manifest here, off the hot + # path (it is only needed for the telemetry write). + local plugin_version="unknown" + local manifest="${SCRIPT_DIR}/../.cursor-plugin/plugin.json" + if [[ -f "$manifest" ]]; then + local manifest_version + manifest_version=$(grep -oE '"version"[[:space:]]*:[[:space:]]*"[^"]*"' "$manifest" \ + | head -1 \ + | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/' 2>/dev/null \ + || echo "") + [[ -n "$manifest_version" ]] && plugin_version="$manifest_version" + fi + + write_execution_event \ + "1password-validate-mounted-env-files" \ + "$plugin_version" \ + "cursor" \ + "before_shell_execution" \ + "$permission" \ + "$deny_reason" \ + "$duration_ms" \ + "$resolved_mode" \ + "$total_mount_count" + + # The Cursor plugin marketplace does not expose an install lifecycle + # hook, so we detect installation via a sentinel keyed by + # (client, hook_name, hook_version). The install event fires exactly + # once per (cursor, 1password-validate-mounted-env-files, version), + # so an upgrade or reinstall produces a fresh install event. + emit_plugin_marketplace_install_event_once \ + "cursor" \ + "1password-validate-mounted-env-files" \ + "$plugin_version" + ) >/dev/null 2>&1 & +} + # ============================================================================ # SYSTEM & PATH UTILITY FUNCTIONS # ============================================================================ @@ -586,8 +666,12 @@ fi # Query 1Password database and check mounts log "Checking for local .env files mounted by 1Password..." -# Read JSON input from stdin and extract workspace_roots -workspace_roots_input=$(parse_json_workspace_roots) +# Read JSON input from stdin and extract workspace_roots. +# `|| true`: parse_json_workspace_roots ends in a grep that exits non-zero when +# the input has no workspace_roots; under `set -euo pipefail` that would abort +# the script (emitting no decision) before the fail-open branch below. Swallow +# it so empty/absent input reaches that branch and still returns an "allow". +workspace_roots_input=$(parse_json_workspace_roots) || true workspace_roots_array=() # Build array of workspace roots @@ -604,6 +688,7 @@ done <<< "$workspace_roots_input" if [[ ${#workspace_roots_array[@]} -eq 0 ]]; then log "No workspace_roots found in JSON input, skipping validation" output_response + emit_telemetry_async exit 0 fi @@ -632,6 +717,7 @@ for workspace_root in "${workspace_roots_array[@]}"; do if has_toml_mount_paths_field "$toml_file"; then use_configured_mode=true + resolved_mode="configured" log "environments.toml has mount_paths field defined - validating specified mounts" # Parse and validate TOML mount paths @@ -699,6 +785,7 @@ for workspace_root in "${workspace_roots_array[@]}"; do # Check each TOML-specified mount if [[ ${#toml_paths_array[@]} -gt 0 ]]; then for resolved_path in "${toml_paths_array[@]}"; do + ((total_mount_count++)) || true log "Checking required local .env file from TOML: \"${resolved_path}\"" # First, check if it's in the database and what its status is @@ -763,6 +850,7 @@ for workspace_root in "${workspace_roots_array[@]}"; do fi else # Default mode: Check all local .env files within this workspace from 1Password database + resolved_mode="default" log "Using default mode: checking all local .env files in workspace ${workspace_root} from 1Password database" if [[ -z "$mount_hex_data" ]]; then @@ -796,6 +884,8 @@ for workspace_root in "${workspace_roots_array[@]}"; do continue fi + ((total_mount_count++)) || true + if [[ "$is_enabled" == "true" ]]; then if [[ ! -e "$mount_path" ]] || [[ ! -p "$mount_path" ]]; then log "Local .env file is invalid (file is not present or not a FIFO)" @@ -908,4 +998,14 @@ fi # Output JSON response with permission decision output_response + +# ============================================================================ +# TELEMETRY EMISSION (off the hot path) +# ============================================================================ +# Emit duration, decision, mode, mount count and the once-per-version install +# event in a detached background subshell. Mirrors the early-exit paths above +# so every invocation is recorded exactly once, never on the latency-sensitive +# path before the decision reaches Cursor. +emit_telemetry_async + exit 0