Files
tekton/addons/godot_ai/telemetry.gd

200 lines
7.2 KiB
GDScript

## 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()