Files
engine/modules/agent_analytics/agent_analytics.cpp
ozan d291dcdc74 feat: 9 agentic engine modules for agent-native Godot
agent_api (HTTP server), agent_log (structured logging), agent_events (event bus),
agent_console (GameConsole), agent_replay (snapshots), agent_vision (depth/segmentation),
agent_fbx (bone remapping), agent_auth (multi-agent), agent_analytics (feature flags + tracking)

All modules compile clean with mono. Binary uploaded to S3 v1.0.0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-15 03:44:28 +01:00

529 lines
16 KiB
C++

#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<String, Funnel> &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<String, FeatureFlag> &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<FileAccess> 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<FileAccess> file = FileAccess::open(p_path, FileAccess::WRITE);
if (file.is_null()) {
return false;
}
Dictionary data;
MutexLock lock(flags_mutex);
for (const KeyValue<String, FeatureFlag> &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<FileAccess> 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);
}