d291dcdc74
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>
529 lines
16 KiB
C++
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);
|
|
}
|