## Plugin-side telemetry helper. ## ## Relays plugin-only events (dock startup, self-update outcome, plugin ## reload, dev-server toggle) to the Python MCP server via the existing ## `send_event("plugin_event", {...})` channel. The server's ## `transport/websocket.py` allowlists event names and forwards into the ## central telemetry pipeline — meaning opt-out, endpoint, customer_uuid ## and the bounded-queue worker stay in one place (Python), not ## duplicated in GDScript. ## ## Opt-out options priority: ## 1. `GODOT_AI_DISABLE_TELEMETRY` / `DISABLE_TELEMETRY` env vars — ## checked first so CI / operators can force-disable without touching ## EditorSettings. ## 2. The `godot_ai/telemetry_enabled` EditorSetting — set through the ## MCP dock and persisted between sessions. ## ## When telemetry is disabled, events are never buffered or sent. Only a ## *truthy* env var force-disables; a falsey or absent env var falls through ## to the EditorSetting (which defaults to enabled). See McpSettings.telemetry_enabled. ## ## Buffering: events recorded before the WebSocket is connected go into ## a small bounded buffer and flush on the next `record_event` call once ## connected. The buffer is intentionally small (`_MAX_BUFFER`); plugin ## events are sparse, and a flood means something is misconfigured. extends RefCounted ## Allowlist mirrored on the Python side in ## `src/godot_ai/transport/websocket.py::_PLUGIN_EVENT_NAMES`. Update ## both together. const _ALLOWED_EVENTS := [ "dock_startup", "plugin_reload", "self_update", "dev_server_toggle", ] const _MAX_BUFFER := 32 ## EditorSetting key used to defer a ``plugin_reload`` event across the ## disable -> enable boundary. Callers that trigger plugin reload (the ## dock reload button, ``editor_reload_plugin`` MCP-tool path) write ## here *before* the disable kills the live WebSocket; the new ## plugin's ``_enter_tree`` flushes via ``flush_pending_plugin_reload``. const PENDING_PLUGIN_RELOAD_KEY := "godot_ai/pending_plugin_reload_event" ## Persist a ``plugin_reload`` event so the re-enabled plugin instance ## can emit it once its new WebSocket is up. Static so callers without ## a telemetry instance handle (e.g. ``editor_handler.reload_plugin``) ## can use it via the preloaded const alias. static func record_pending_plugin_reload(source: String) -> void: var settings := EditorInterface.get_editor_settings() if settings == null: return settings.set_setting( PENDING_PLUGIN_RELOAD_KEY, JSON.stringify({"source": source, "success": true}), ) ## Read + clear an EditorSetting JSON-encoded event payload. Returns ## the parsed dict, or ``null`` if the key is absent / empty / ## malformed. Used by ``flush_pending_plugin_reload`` (below) and by ## ``plugin.gd::_flush_pending_self_update_telemetry``. Centralising ## the read-and-clear dance keeps both flush sites symmetric with the ## ``record_pending_*`` writers and prevents the "key gets stuck" ## class of bug if a future flush helper forgets the clear step. static func _drain_editor_setting_dict(key: String): var settings := EditorInterface.get_editor_settings() if settings == null: return null if not settings.has_setting(key): return null var raw := str(settings.get_setting(key)) settings.set_setting(key, "") if raw == "": return null var parsed = JSON.parse_string(raw) if typeof(parsed) != TYPE_DICTIONARY: return null return parsed var _connection var _disabled: bool = false var _pending: Array = [] # of {name: String, data: Dictionary} func _init(connection) -> void: _connection = connection _disabled = not McpSettings.telemetry_enabled() ## Subscribe to ``connection_state_changed`` so events buffered before ## the WebSocket handshake (e.g. ``record_dock_startup`` from ## ``plugin._enter_tree``) actually leave the editor. Without this, ## the buffer only drained on the next ``record_event`` call — when ## that call never came (the common single-session case), the very ## events we cared about most sat in the queue forever. if _connection != null and _connection.has_signal("connection_state_changed"): _connection.connection_state_changed.connect(_on_connection_state_changed) func record_event(name: String, data: Dictionary = {}) -> void: if _disabled: return if not _ALLOWED_EVENTS.has(name): ## Drop silently — matches the server's behavior for unknown ## names, and avoids editor yellow-bar noise from third-party ## callers or stale event names mid-rollout. return if _connection != null and _connection.is_connected: _flush() _send_one(name, data) return ## Pre-handshake: stash in a small bounded buffer. Drained on the ## first ``connection_state_changed(true)`` after this point (see ## ``_on_connection_state_changed``). Falling back to "drain on the ## next record_event" is a footgun: the most useful plugin events ## (``dock_startup``, pending ``self_update``) fire from ## ``plugin._enter_tree`` before the handshake, and a single-session ## editor may never emit a second event — so without the signal- ## driven flush they sat buffered forever. if _pending.size() >= _MAX_BUFFER: _pending.pop_front() _pending.append({"name": name, "data": data}) func _on_connection_state_changed(is_open: bool) -> void: if is_open: _flush() func _flush() -> void: if _pending.is_empty(): return var to_send := _pending.duplicate() _pending.clear() for entry in to_send: _send_one(entry["name"], entry["data"]) func _send_one(name: String, data: Dictionary) -> void: if _connection == null: return _connection.send_event("plugin_event", {"name": name, "data": data}) # --- convenience emitters -------------------------------------------------- func record_dock_startup(extra: Dictionary = {}) -> void: record_event("dock_startup", extra) func record_plugin_reload(success: bool, error: String = "") -> void: var data := {"success": success} if error != "": data["error"] = error.substr(0, 200) record_event("plugin_reload", data) func record_self_update( status: String, from_version: String = "", to_version: String = "", error: String = "", ) -> void: var data := {"status": status} if from_version != "": data["from_version"] = from_version if to_version != "": data["to_version"] = to_version if error != "": data["error"] = error.substr(0, 200) record_event("self_update", data) func record_dev_server_toggle(action: String) -> void: record_event("dev_server_toggle", {"action": action}) ## Drain a pending ``plugin_reload`` event written by the previous ## instance before it disabled itself. func flush_pending_plugin_reload() -> void: var parsed = _drain_editor_setting_dict(PENDING_PLUGIN_RELOAD_KEY) if parsed == null: return var data := { "success": bool(parsed.get("success", true)), "source": str(parsed.get("source", "unknown")), } var error := str(parsed.get("error", "")) if error != "": data["error"] = error.substr(0, 200) record_event("plugin_reload", data) # --- test seam ------------------------------------------------------------- ## Inject a fake connection or force the disabled flag for unit tests ## that don't have a live WebSocket. Production code does not call this. func _test_set_state(connection, disabled: bool) -> void: _connection = connection _disabled = disabled _pending.clear() func _test_pending_count() -> int: return _pending.size()