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
2 changes: 1 addition & 1 deletion .cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "1password",
"version": "1.0.1",
"version": "1.1.0",
"description": "1Password plugin for Cursor — securely manage development secrets.",
"author": {
"name": "1Password"
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
191 changes: 191 additions & 0 deletions scripts/lib/telemetry.sh
Original file line number Diff line number Diff line change
@@ -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
}
Loading