#include "agent_analytics.h" #include "core/config/engine.h" #include "core/io/file_access.h" #include "core/io/json.h" #include "core/os/os.h" #include "core/string/print_string.h" #include "core/math/math_funcs.h" AgentAnalytics *AgentAnalytics::singleton = nullptr; AgentAnalytics::AgentAnalytics() { singleton = this; } AgentAnalytics::~AgentAnalytics() { singleton = nullptr; } void AgentAnalytics::_bind_methods() { // Event tracking. ClassDB::bind_method(D_METHOD("capture", "event", "properties"), &AgentAnalytics::capture, DEFVAL(Dictionary())); ClassDB::bind_method(D_METHOD("identify", "distinct_id", "properties"), &AgentAnalytics::identify, DEFVAL(Dictionary())); ClassDB::bind_method(D_METHOD("screen", "screen_name", "properties"), &AgentAnalytics::screen, DEFVAL(Dictionary())); ClassDB::bind_method(D_METHOD("start_session", "distinct_id"), &AgentAnalytics::start_session, DEFVAL("")); ClassDB::bind_method(D_METHOD("end_session"), &AgentAnalytics::end_session); ClassDB::bind_method(D_METHOD("get_session_id"), &AgentAnalytics::get_session_id); // Feature flags. ClassDB::bind_method(D_METHOD("is_feature_enabled", "key", "context"), &AgentAnalytics::is_feature_enabled, DEFVAL(Dictionary())); ClassDB::bind_method(D_METHOD("get_feature_flag", "key", "default"), &AgentAnalytics::get_feature_flag, DEFVAL(Variant())); ClassDB::bind_method(D_METHOD("set_feature_flag", "key", "enabled", "value", "rollout", "description"), &AgentAnalytics::set_feature_flag, DEFVAL(Variant()), DEFVAL(100.0f), DEFVAL("")); ClassDB::bind_method(D_METHOD("remove_feature_flag", "key"), &AgentAnalytics::remove_feature_flag); ClassDB::bind_method(D_METHOD("get_all_flags"), &AgentAnalytics::get_all_flags); ClassDB::bind_method(D_METHOD("load_flags_from_file", "path"), &AgentAnalytics::load_flags_from_file, DEFVAL("res://feature_flags.json")); ClassDB::bind_method(D_METHOD("save_flags_to_file", "path"), &AgentAnalytics::save_flags_to_file, DEFVAL("res://feature_flags.json")); // A/B testing. ClassDB::bind_method(D_METHOD("get_experiment_group", "experiment_key", "groups"), &AgentAnalytics::get_experiment_group); ClassDB::bind_method(D_METHOD("get_experiment_results", "experiment_key"), &AgentAnalytics::get_experiment_results); // Funnels. ClassDB::bind_method(D_METHOD("define_funnel", "name", "steps"), &AgentAnalytics::define_funnel); ClassDB::bind_method(D_METHOD("get_funnel_stats", "name"), &AgentAnalytics::get_funnel_stats); // Query. ClassDB::bind_method(D_METHOD("get_events", "count", "event_name", "since_msec"), &AgentAnalytics::get_events, DEFVAL(100), DEFVAL(""), DEFVAL(0)); ClassDB::bind_method(D_METHOD("get_event_count", "event_name"), &AgentAnalytics::get_event_count, DEFVAL("")); ClassDB::bind_method(D_METHOD("get_event_counts_by_name"), &AgentAnalytics::get_event_counts_by_name); // Config. ClassDB::bind_method(D_METHOD("enable_file_sink", "path"), &AgentAnalytics::enable_file_sink, DEFVAL("user://analytics.jsonl")); ClassDB::bind_method(D_METHOD("disable_file_sink"), &AgentAnalytics::disable_file_sink); ClassDB::bind_method(D_METHOD("set_remote_endpoint", "endpoint", "api_key"), &AgentAnalytics::set_remote_endpoint); ClassDB::bind_method(D_METHOD("disable_remote"), &AgentAnalytics::disable_remote); ClassDB::bind_method(D_METHOD("set_distinct_id", "id"), &AgentAnalytics::set_distinct_id); ClassDB::bind_method(D_METHOD("get_distinct_id"), &AgentAnalytics::get_distinct_id); // Super properties. ClassDB::bind_method(D_METHOD("set_super_property", "key", "value"), &AgentAnalytics::set_super_property); ClassDB::bind_method(D_METHOD("remove_super_property", "key"), &AgentAnalytics::remove_super_property); ClassDB::bind_method(D_METHOD("clear_super_properties"), &AgentAnalytics::clear_super_properties); ClassDB::bind_method(D_METHOD("clear"), &AgentAnalytics::clear); } // --- Event Tracking --- void AgentAnalytics::capture(const String &p_event, const Dictionary &p_properties) { AnalyticsEvent evt; evt.timestamp_msec = OS::get_singleton()->get_ticks_msec(); evt.event_name = p_event; evt.distinct_id = current_distinct_id; evt.properties = p_properties; // Merge super properties. for (const Variant &key : super_properties.keys()) { if (!evt.properties.has(key)) { evt.properties[key] = super_properties[key]; } } // Add session info. if (!current_session_id.is_empty()) { evt.properties["$session_id"] = current_session_id; } { MutexLock lock(events_mutex); evt.id = next_event_id++; events.push_back(evt); if (events.size() > EVENT_BUFFER_SIZE) { events.remove_at(0); } } // Update funnel tracking. for (KeyValue &kv : funnels) { Array steps = kv.value.steps; for (int i = 0; i < steps.size(); i++) { if (String(steps[i]) == p_event) { String step_key = vformat("step_%d_%s", i, p_event); int count = kv.value.step_counts.has(step_key) ? (int)kv.value.step_counts[step_key] : 0; kv.value.step_counts[step_key] = count + 1; } } } // Write to file sink. if (file_sink_enabled) { _write_event_to_file(evt); } // Send to remote. if (remote_enabled) { _send_event_to_remote(evt); } } void AgentAnalytics::identify(const String &p_distinct_id, const Dictionary &p_properties) { current_distinct_id = p_distinct_id; Dictionary props = p_properties; props["$set"] = p_properties; capture("$identify", props); } void AgentAnalytics::alias(const String &p_alias, const String &p_distinct_id) { Dictionary props; props["alias"] = p_alias; props["distinct_id"] = p_distinct_id; capture("$create_alias", props); } void AgentAnalytics::screen(const String &p_screen_name, const Dictionary &p_properties) { Dictionary props = p_properties; props["$screen_name"] = p_screen_name; capture("$screen", props); } // --- Session --- void AgentAnalytics::start_session(const String &p_distinct_id) { if (!p_distinct_id.is_empty()) { current_distinct_id = p_distinct_id; } current_session_id = _generate_session_id(); session_start = OS::get_singleton()->get_ticks_msec(); Dictionary props; props["$session_id"] = current_session_id; capture("$session_start", props); } void AgentAnalytics::end_session() { if (current_session_id.is_empty()) { return; } Dictionary props; props["$session_id"] = current_session_id; props["$session_duration_ms"] = OS::get_singleton()->get_ticks_msec() - session_start; capture("$session_end", props); current_session_id = ""; } // --- Feature Flags --- bool AgentAnalytics::is_feature_enabled(const String &p_key, const Dictionary &p_context) { MutexLock lock(flags_mutex); if (!feature_flags.has(p_key)) { return false; } const FeatureFlag &flag = feature_flags[p_key]; if (!flag.enabled) { return false; } // Rollout percentage check. if (flag.rollout_percentage < 100.0f) { // Deterministic hash based on distinct_id + flag key for consistent assignment. String hash_input = current_distinct_id + ":" + p_key; uint32_t hash = hash_input.hash(); float percentage = (float)(hash % 10000) / 100.0f; if (percentage >= flag.rollout_percentage) { return false; } } // Condition evaluation. if (!flag.conditions.is_empty()) { return _evaluate_flag_conditions(flag, p_context); } return true; } Variant AgentAnalytics::get_feature_flag(const String &p_key, const Variant &p_default) { MutexLock lock(flags_mutex); if (!feature_flags.has(p_key)) { return p_default; } const FeatureFlag &flag = feature_flags[p_key]; if (!flag.enabled) { return p_default; } return flag.value.get_type() != Variant::NIL ? flag.value : p_default; } void AgentAnalytics::set_feature_flag(const String &p_key, bool p_enabled, const Variant &p_value, float p_rollout, const String &p_description) { MutexLock lock(flags_mutex); FeatureFlag flag; flag.key = p_key; flag.enabled = p_enabled; flag.value = p_value; flag.rollout_percentage = p_rollout; flag.description = p_description; feature_flags[p_key] = flag; // Track the flag change. Dictionary props; props["flag_key"] = p_key; props["enabled"] = p_enabled; props["rollout"] = p_rollout; capture("$feature_flag_changed", props); } void AgentAnalytics::remove_feature_flag(const String &p_key) { MutexLock lock(flags_mutex); feature_flags.erase(p_key); } Array AgentAnalytics::get_all_flags() const { Array result; for (const KeyValue &kv : feature_flags) { Dictionary dict; dict["key"] = kv.value.key; dict["enabled"] = kv.value.enabled; dict["value"] = kv.value.value; dict["rollout_percentage"] = kv.value.rollout_percentage; dict["description"] = kv.value.description; result.push_back(dict); } return result; } bool AgentAnalytics::load_flags_from_file(const String &p_path) { if (!FileAccess::exists(p_path)) { return false; } Ref file = FileAccess::open(p_path, FileAccess::READ); if (file.is_null()) { return false; } Variant parsed = JSON::parse_string(file->get_as_text()); if (parsed.get_type() != Variant::DICTIONARY) { return false; } Dictionary data = parsed; MutexLock lock(flags_mutex); for (const Variant &key : data.keys()) { Dictionary flag_data = data[key]; FeatureFlag flag; flag.key = key; flag.enabled = flag_data.get("enabled", false); flag.value = flag_data.get("value", Variant()); flag.rollout_percentage = flag_data.get("rollout_percentage", 100.0f); flag.description = flag_data.get("description", ""); feature_flags[String(key)] = flag; } print_line(vformat("AgentAnalytics: Loaded %d feature flags from '%s'", data.size(), p_path)); return true; } bool AgentAnalytics::save_flags_to_file(const String &p_path) { Ref file = FileAccess::open(p_path, FileAccess::WRITE); if (file.is_null()) { return false; } Dictionary data; MutexLock lock(flags_mutex); for (const KeyValue &kv : feature_flags) { Dictionary flag_dict; flag_dict["enabled"] = kv.value.enabled; flag_dict["value"] = kv.value.value; flag_dict["rollout_percentage"] = kv.value.rollout_percentage; flag_dict["description"] = kv.value.description; data[kv.key] = flag_dict; } file->store_string(JSON::stringify(data, "\t")); return true; } // --- A/B Testing --- String AgentAnalytics::get_experiment_group(const String &p_experiment_key, const Array &p_groups) { if (experiment_groups.has(p_experiment_key)) { return experiment_groups[p_experiment_key]; } if (p_groups.is_empty()) { return ""; } // Deterministic assignment based on distinct_id + experiment key. String hash_input = current_distinct_id + ":" + p_experiment_key; uint32_t hash = hash_input.hash(); int group_idx = hash % p_groups.size(); String group = p_groups[group_idx]; experiment_groups[p_experiment_key] = group; // Track assignment. Dictionary props; props["experiment"] = p_experiment_key; props["group"] = group; capture("$experiment_assigned", props); return group; } Dictionary AgentAnalytics::get_experiment_results(const String &p_experiment_key) { Dictionary results; results["experiment"] = p_experiment_key; // Count events per group. Dictionary group_counts; MutexLock lock(events_mutex); for (const AnalyticsEvent &evt : events) { if (evt.properties.has("experiment") && String(evt.properties["experiment"]) == p_experiment_key) { String group = evt.properties.get("group", "unknown"); int count = group_counts.has(group) ? (int)group_counts[group] : 0; group_counts[group] = count + 1; } } results["group_counts"] = group_counts; return results; } // --- Funnels --- void AgentAnalytics::define_funnel(const String &p_name, const Array &p_steps) { Funnel funnel; funnel.name = p_name; funnel.steps = p_steps; funnels[p_name] = funnel; } Dictionary AgentAnalytics::get_funnel_stats(const String &p_name) { Dictionary result; if (!funnels.has(p_name)) { result["error"] = "Funnel not found"; return result; } const Funnel &funnel = funnels[p_name]; result["name"] = funnel.name; result["steps"] = funnel.steps; result["step_counts"] = funnel.step_counts; // Calculate conversion rates. Array conversions; for (int i = 0; i < funnel.steps.size(); i++) { String step_key = vformat("step_%d_%s", i, String(funnel.steps[i])); int count = funnel.step_counts.has(step_key) ? (int)funnel.step_counts[step_key] : 0; Dictionary step_info; step_info["step"] = funnel.steps[i]; step_info["count"] = count; if (i > 0) { String prev_key = vformat("step_%d_%s", i - 1, String(funnel.steps[i - 1])); int prev_count = funnel.step_counts.has(prev_key) ? (int)funnel.step_counts[prev_key] : 0; step_info["conversion_rate"] = prev_count > 0 ? (float)count / (float)prev_count * 100.0f : 0.0f; } conversions.push_back(step_info); } result["conversions"] = conversions; return result; } // --- Query --- Array AgentAnalytics::get_events(int p_count, const String &p_event_name, uint64_t p_since_msec) { Array result; MutexLock lock(events_mutex); for (int i = events.size() - 1; i >= 0 && result.size() < p_count; i--) { const AnalyticsEvent &evt = events[i]; if (!p_event_name.is_empty() && evt.event_name != p_event_name) { continue; } if (p_since_msec > 0 && evt.timestamp_msec < p_since_msec) { break; } Dictionary dict; dict["id"] = evt.id; dict["timestamp_msec"] = evt.timestamp_msec; dict["event"] = evt.event_name; dict["distinct_id"] = evt.distinct_id; dict["properties"] = evt.properties; result.push_back(dict); } return result; } int AgentAnalytics::get_event_count(const String &p_event_name) { MutexLock lock(events_mutex); if (p_event_name.is_empty()) { return events.size(); } int count = 0; for (const AnalyticsEvent &evt : events) { if (evt.event_name == p_event_name) { count++; } } return count; } Dictionary AgentAnalytics::get_event_counts_by_name() { Dictionary result; MutexLock lock(events_mutex); for (const AnalyticsEvent &evt : events) { int count = result.has(evt.event_name) ? (int)result[evt.event_name] : 0; result[evt.event_name] = count + 1; } return result; } // --- Config --- void AgentAnalytics::enable_file_sink(const String &p_path) { file_sink_path = p_path; file_sink_enabled = true; } void AgentAnalytics::disable_file_sink() { file_sink_enabled = false; } void AgentAnalytics::set_remote_endpoint(const String &p_endpoint, const String &p_api_key) { remote_endpoint = p_endpoint; remote_api_key = p_api_key; remote_enabled = true; } void AgentAnalytics::disable_remote() { remote_enabled = false; } void AgentAnalytics::set_super_property(const String &p_key, const Variant &p_value) { super_properties[p_key] = p_value; } void AgentAnalytics::remove_super_property(const String &p_key) { super_properties.erase(p_key); } void AgentAnalytics::clear_super_properties() { super_properties.clear(); } void AgentAnalytics::clear() { MutexLock lock(events_mutex); events.clear(); next_event_id = 1; } // --- Private --- void AgentAnalytics::_write_event_to_file(const AnalyticsEvent &p_event) { Ref file = FileAccess::open(file_sink_path, FileAccess::READ_WRITE); if (file.is_null()) { file = FileAccess::open(file_sink_path, FileAccess::WRITE); } if (file.is_null()) { return; } file->seek_end(); Dictionary dict; dict["id"] = p_event.id; dict["timestamp_msec"] = p_event.timestamp_msec; dict["event"] = p_event.event_name; dict["distinct_id"] = p_event.distinct_id; dict["properties"] = p_event.properties; file->store_string(JSON::stringify(dict) + "\n"); } void AgentAnalytics::_send_event_to_remote(const AnalyticsEvent &p_event) { // HTTP POST to remote endpoint. // For now, this is a stub. Full implementation would use HTTPClient. // Events can be batched for efficiency. } bool AgentAnalytics::_evaluate_flag_conditions(const FeatureFlag &p_flag, const Dictionary &p_context) { // Simple condition evaluation: check if context matches all conditions. for (const Variant &key : p_flag.conditions.keys()) { if (!p_context.has(key)) { return false; } if (p_context[key] != p_flag.conditions[key]) { return false; } } return true; } String AgentAnalytics::_generate_session_id() { return vformat("session_%d_%d", OS::get_singleton()->get_ticks_msec(), Math::rand() % 10000); }