Add runtime controls for Shardok latency tracing

This commit is contained in:
2026-06-07 09:21:17 -07:00
parent 9c60652eaa
commit 6aa887a3e9
17 changed files with 576 additions and 71 deletions
@@ -3,6 +3,7 @@
#include <cstdlib>
#include <iostream>
#include <mutex>
#include <optional>
#include <sstream>
namespace net::eagle0::common {
@@ -15,19 +16,56 @@ auto ReadEnvEnabled() -> bool {
return text == "1" || text == "true" || text == "TRUE" || text == "yes" || text == "YES";
}
auto TraceEnabled() -> bool {
static const bool enabled = ReadEnvEnabled();
return enabled;
}
auto ReadGameFilter() -> std::string {
const char* value = std::getenv("EAGLE0_SHARDOK_LATENCY_GAME_ID");
return value == nullptr ? "" : std::string(value);
}
auto GameFilter() -> const std::string& {
static const std::string filter = ReadGameFilter();
return filter;
struct ActiveTraceConfig {
bool enabled = false;
std::string gameId;
std::chrono::seconds ttl = std::chrono::seconds::zero();
double slowSpanThresholdMs = 0.0;
std::optional<std::chrono::steady_clock::time_point> expiresAt;
};
auto ToActiveConfig(const ShardokLatencyTraceConfig& config) -> ActiveTraceConfig {
return ActiveTraceConfig{
.enabled = config.enabled,
.gameId = config.gameId,
.ttl = config.ttl,
.slowSpanThresholdMs = config.slowSpanThresholdMs,
.expiresAt = config.enabled && config.ttl > std::chrono::seconds::zero()
? std::optional(std::chrono::steady_clock::now() + config.ttl)
: std::nullopt};
}
auto ConfigFromEnv() -> ActiveTraceConfig {
return ToActiveConfig(ShardokLatencyTraceConfig{
.enabled = ReadEnvEnabled(),
.gameId = ReadGameFilter(),
.ttl = std::chrono::seconds::zero(),
.slowSpanThresholdMs = 0.0});
}
auto ConfigMutex() -> std::mutex& {
static auto mutex = new std::mutex();
return *mutex;
}
auto Config() -> ActiveTraceConfig& {
static auto config = new ActiveTraceConfig(ConfigFromEnv());
return *config;
}
auto CurrentConfig() -> ActiveTraceConfig {
std::scoped_lock lock(ConfigMutex());
auto& config = Config();
if (config.expiresAt.has_value() && std::chrono::steady_clock::now() >= *config.expiresAt) {
config.enabled = false;
config.expiresAt = std::nullopt;
}
return config;
}
auto JsonEscape(std::string_view value) -> std::string {
@@ -52,10 +90,23 @@ auto LogMutex() -> std::mutex& {
} // namespace
void SetShardokLatencyTraceConfig(const ShardokLatencyTraceConfig& config) {
std::scoped_lock lock(ConfigMutex());
Config() = ToActiveConfig(config);
}
auto GetShardokLatencyTraceConfig() -> ShardokLatencyTraceConfig {
const ActiveTraceConfig config = CurrentConfig();
return ShardokLatencyTraceConfig{
.enabled = config.enabled,
.gameId = config.gameId,
.ttl = config.ttl,
.slowSpanThresholdMs = config.slowSpanThresholdMs};
}
auto ShardokLatencyTraceEnabledForGame(std::string_view gameId) -> bool {
if (!TraceEnabled()) { return false; }
const std::string& filter = GameFilter();
return filter.empty() || filter == gameId;
const ActiveTraceConfig config = CurrentConfig();
return config.enabled && (config.gameId.empty() || config.gameId == gameId);
}
void EmitShardokLatencyTrace(
@@ -63,10 +114,12 @@ void EmitShardokLatencyTrace(
std::string_view gameId,
const ShardokLatencyFields& fields,
std::chrono::steady_clock::duration duration) {
if (!ShardokLatencyTraceEnabledForGame(gameId)) { return; }
const ActiveTraceConfig config = CurrentConfig();
if (!config.enabled || (!config.gameId.empty() && config.gameId != gameId)) { return; }
const auto durationMs =
std::chrono::duration_cast<std::chrono::microseconds>(duration).count() / 1000.0;
if (config.slowSpanThresholdMs > 0.0 && durationMs < config.slowSpanThresholdMs) { return; }
std::ostringstream out;
out << "{\"event\":\"shardok_latency\","
@@ -11,6 +11,16 @@ namespace net::eagle0::common {
using ShardokLatencyFields = std::vector<std::pair<std::string, std::string>>;
struct ShardokLatencyTraceConfig {
bool enabled = false;
std::string gameId;
std::chrono::seconds ttl = std::chrono::seconds::zero();
double slowSpanThresholdMs = 0.0;
};
void SetShardokLatencyTraceConfig(const ShardokLatencyTraceConfig& config);
auto GetShardokLatencyTraceConfig() -> ShardokLatencyTraceConfig;
auto ShardokLatencyTraceEnabledForGame(std::string_view gameId) -> bool;
void EmitShardokLatencyTrace(
@@ -9,6 +9,7 @@
#include "EagleInterfaceGrpcServer.hpp"
#include <algorithm>
#include <chrono>
#include <iterator>
#include <memory>
#include <ranges>
@@ -29,8 +30,11 @@ namespace shardok {
using grpc::Status;
using grpc::StatusCode;
using net::eagle0::common::GameSetupInfo;
using net::eagle0::common::GetShardokLatencyTraceConfig;
using net::eagle0::common::ScopedShardokLatencyTrace;
using net::eagle0::common::SetShardokLatencyTraceConfig;
using net::eagle0::common::ShardokLatencyFields;
using net::eagle0::common::ShardokLatencyTraceConfig;
using net::eagle0::common::VictoryCondition;
using net::eagle0::shardok::api::PlacementCommand;
using net::eagle0::shardok::common::EndGameCondition;
@@ -452,6 +456,47 @@ auto EagleInterfaceImpl::GetHexMapNames(
return Status::OK;
}
namespace {
void PopulateLatencyTraceConfigProto(
const ShardokLatencyTraceConfig &config,
net::eagle0::common::LatencyTraceConfig *proto) {
proto->set_enabled(config.enabled);
proto->set_game_id(config.gameId);
proto->set_ttl_seconds(config.ttl.count());
proto->set_slow_span_threshold_ms(config.slowSpanThresholdMs);
}
auto LatencyTraceConfigFromProto(const net::eagle0::common::LatencyTraceConfig &proto)
-> ShardokLatencyTraceConfig {
return ShardokLatencyTraceConfig{
.enabled = proto.enabled(),
.gameId = proto.game_id(),
.ttl = std::chrono::seconds(proto.ttl_seconds()),
.slowSpanThresholdMs = proto.slow_span_threshold_ms()};
}
} // namespace
auto EagleInterfaceImpl::GetLatencyTraceConfig(
ServerContext *context,
const GetLatencyTraceConfigRequest * /*request*/,
GetLatencyTraceConfigResponse *response) -> Status {
if (auto authStatus = tokenValidator_.ValidateOrStatus(context)) { return *authStatus; }
PopulateLatencyTraceConfigProto(GetShardokLatencyTraceConfig(), response->mutable_config());
return Status::OK;
}
auto EagleInterfaceImpl::SetLatencyTraceConfig(
ServerContext *context,
const SetLatencyTraceConfigRequest *request,
SetLatencyTraceConfigResponse *response) -> Status {
if (auto authStatus = tokenValidator_.ValidateOrStatus(context)) { return *authStatus; }
SetShardokLatencyTraceConfig(LatencyTraceConfigFromProto(request->config()));
PopulateLatencyTraceConfigProto(GetShardokLatencyTraceConfig(), response->mutable_config());
return Status::OK;
}
auto EagleInterfaceImpl::SubscribeToGame(
ServerContext *context,
const GameSubscriptionRequest *request,
@@ -34,6 +34,8 @@ using grpc::Status;
using net::eagle0::common::GameSetupInfo;
using net::eagle0::common::GameStatusResponse;
using net::eagle0::common::GameSubscriptionRequest;
using net::eagle0::common::GetLatencyTraceConfigRequest;
using net::eagle0::common::GetLatencyTraceConfigResponse;
using net::eagle0::common::HexMapNamesRequest;
using net::eagle0::common::HexMapNamesResponse;
using net::eagle0::common::HexMapRequest;
@@ -41,6 +43,8 @@ using net::eagle0::common::HexMapResponse;
using net::eagle0::common::NewGameRequest;
using net::eagle0::common::PlacementCommandsRequest;
using net::eagle0::common::PostCommandRequest;
using net::eagle0::common::SetLatencyTraceConfigRequest;
using net::eagle0::common::SetLatencyTraceConfigResponse;
using net::eagle0::common::ShardokInternalInterface;
using std::shared_ptr;
@@ -83,6 +87,14 @@ public:
ServerContext* context,
const HexMapNamesRequest* request,
HexMapNamesResponse* response) -> Status override;
auto GetLatencyTraceConfig(
ServerContext* context,
const GetLatencyTraceConfigRequest* request,
GetLatencyTraceConfigResponse* response) -> Status override;
auto SetLatencyTraceConfig(
ServerContext* context,
const SetLatencyTraceConfigRequest* request,
SetLatencyTraceConfigResponse* response) -> Status override;
auto SubscribeToGame(
ServerContext* context,
@@ -15,6 +15,7 @@ go_library(
visibility = ["//visibility:private"],
deps = [
"//src/main/go/net/eagle0/util/aws",
"//src/main/protobuf/net/eagle0/common:shardok_internal_interface_go_grpc",
"//src/main/protobuf/net/eagle0/eagle/admin:game_admin_go_proto",
"//src/main/protobuf/net/eagle0/eagle/api:api_go_proto", # keep
"//src/main/protobuf/net/eagle0/eagle/api/admin:admin_go_proto",
@@ -23,10 +23,11 @@ import (
"strings"
"time"
"github.com/nolen777/eagle0/src/main/go/net/eagle0/util/aws"
commonpb "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/common/shardok_internal_interface"
gameadminpb "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/admin"
adminpb "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/api/admin"
authpb "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/api/eagle"
gameadminpb "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/admin"
"github.com/nolen777/eagle0/src/main/go/net/eagle0/util/aws"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
@@ -61,35 +62,36 @@ func getBuildInfo() BuildInfo {
var eagleContainerName string
var (
eagleAddr = flag.String("eagle-addr", "localhost:40032", "Eagle gRPC server address")
authAddr = flag.String("auth-addr", "localhost:40033", "Auth gRPC server address")
authTLS = flag.Bool("auth-tls", false, "Use TLS for auth server connection (required for remote auth servers)")
jfrSidecarAddr = flag.String("jfr-sidecar-addr", "localhost:8081", "JFR sidecar HTTP address")
httpPort = flag.Int("http-port", 8080, "HTTP server port")
mapsFlag = flag.String("maps-dir", "", "Directory containing .e0mj map files (enables map editor)")
githubRepoFlag = flag.String("github-repo", "nolen777/eagle0", "GitHub repo for map PR creation")
mapsRepoPathFlag = flag.String("maps-repo-path", "src/main/resources/net/eagle0/shardok/maps", "Path to maps dir in repo")
gameAdminClient gameadminpb.GameAdminClient
adminClient adminpb.AdminClient
authClient authpb.AuthClient
gamesTemplate *template.Template
gameDetailTemplate *template.Template
historyRowsTemplate *template.Template
actionDetailTemplate *template.Template
gameStateTemplate *template.Template
settingsTemplate *template.Template
settingsRowsTemplate *template.Template
usersTemplate *template.Template
usersRowsTemplate *template.Template
invitationsTemplate *template.Template
invitationsRowsTemplate *template.Template
accountsTemplate *template.Template
loginTemplate *template.Template
myAccountTemplate *template.Template
goodbyeTemplate *template.Template
whatsNewTemplate *template.Template
llmStatsTemplate *template.Template
errorTemplate *template.Template
eagleAddr = flag.String("eagle-addr", "localhost:40032", "Eagle gRPC server address")
authAddr = flag.String("auth-addr", "localhost:40033", "Auth gRPC server address")
authTLS = flag.Bool("auth-tls", false, "Use TLS for auth server connection (required for remote auth servers)")
jfrSidecarAddr = flag.String("jfr-sidecar-addr", "localhost:8081", "JFR sidecar HTTP address")
httpPort = flag.Int("http-port", 8080, "HTTP server port")
mapsFlag = flag.String("maps-dir", "", "Directory containing .e0mj map files (enables map editor)")
githubRepoFlag = flag.String("github-repo", "nolen777/eagle0", "GitHub repo for map PR creation")
mapsRepoPathFlag = flag.String("maps-repo-path", "src/main/resources/net/eagle0/shardok/maps", "Path to maps dir in repo")
gameAdminClient gameadminpb.GameAdminClient
adminClient adminpb.AdminClient
authClient authpb.AuthClient
gamesTemplate *template.Template
gameDetailTemplate *template.Template
historyRowsTemplate *template.Template
actionDetailTemplate *template.Template
gameStateTemplate *template.Template
settingsTemplate *template.Template
settingsRowsTemplate *template.Template
usersTemplate *template.Template
usersRowsTemplate *template.Template
invitationsTemplate *template.Template
invitationsRowsTemplate *template.Template
accountsTemplate *template.Template
loginTemplate *template.Template
myAccountTemplate *template.Template
goodbyeTemplate *template.Template
whatsNewTemplate *template.Template
llmStatsTemplate *template.Template
latencyTraceTemplate *template.Template
errorTemplate *template.Template
)
func main() {
@@ -182,6 +184,11 @@ func main() {
log.Fatalf("Failed to parse llm_stats template: %v", err)
}
latencyTraceTemplate, err = template.ParseFS(templatesFS, "templates/layout.html", "templates/latency_trace.html")
if err != nil {
log.Fatalf("Failed to parse latency_trace template: %v", err)
}
errorTemplate, err = template.ParseFS(templatesFS, "templates/layout.html", "templates/error.html")
if err != nil {
log.Fatalf("Failed to parse error template: %v", err)
@@ -254,6 +261,7 @@ func main() {
http.HandleFunc("/settings", requireAuth(handleSettingsPage))
http.HandleFunc("/settings/", requireAuth(handleSettingsRoutes))
http.HandleFunc("/llm-stats", requireAuth(handleLlmStatsPage))
http.HandleFunc("/latency-trace", requireAuth(handleLatencyTracePage))
http.HandleFunc("/accounts", requireAuth(handleAccountsPage))
http.HandleFunc("/users", requireAuth(handleUsersPage))
http.HandleFunc("/users/", requireAuth(handleUserRoutes))
@@ -268,6 +276,7 @@ func main() {
http.HandleFunc("/logout", handleLogout)
http.HandleFunc("/api/games", requireAuth(handleGamesAPI))
http.HandleFunc("/api/games/", requireAuth(handleGameHistoryAPI))
http.HandleFunc("/api/latency-trace", requireAuth(handleLatencyTraceAPI))
http.HandleFunc("/jfr/status", requireAuth(handleJfrStatus))
http.HandleFunc("/jfr/start", requireAuth(handleJfrStart))
http.HandleFunc("/jfr/stop", requireAuth(handleJfrStop))
@@ -1974,6 +1983,139 @@ func handleLlmStatsPage(w http.ResponseWriter, r *http.Request) {
}
}
type latencyTraceConfigView struct {
Enabled bool `json:"enabled"`
GameID string `json:"game_id"`
TTLSeconds int64 `json:"ttl_seconds"`
SlowSpanThresholdMs float64 `json:"slow_span_threshold_ms"`
}
type LatencyTracePageData struct {
Title string
EagleConfig latencyTraceConfigView
ShardokConfig latencyTraceConfigView
Error string
BuildInfo BuildInfo
}
func latencyTraceConfigFromProto(config *commonpb.LatencyTraceConfig) latencyTraceConfigView {
if config == nil {
return latencyTraceConfigView{}
}
return latencyTraceConfigView{
Enabled: config.Enabled,
GameID: config.GameId,
TTLSeconds: config.TtlSeconds,
SlowSpanThresholdMs: config.SlowSpanThresholdMs,
}
}
func latencyTraceConfigToProto(config latencyTraceConfigView) *commonpb.LatencyTraceConfig {
return &commonpb.LatencyTraceConfig{
Enabled: config.Enabled,
GameId: strings.TrimSpace(config.GameID),
TtlSeconds: config.TTLSeconds,
SlowSpanThresholdMs: config.SlowSpanThresholdMs,
}
}
func handleLatencyTracePage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
ctx, cancel := createAdminContext(r)
defer cancel()
resp, err := gameAdminClient.GetShardokLatencyTraceConfig(ctx, &gameadminpb.GetShardokLatencyTraceConfigRequest{})
data := LatencyTracePageData{
Title: "Shardok Latency Trace",
BuildInfo: getBuildInfo(),
}
if err != nil {
log.Printf("Failed to get latency trace config: %v", err)
data.Error = err.Error()
} else {
data.EagleConfig = latencyTraceConfigFromProto(resp.EagleConfig)
data.ShardokConfig = latencyTraceConfigFromProto(resp.ShardokConfig)
data.Error = resp.Error
}
w.Header().Set("Content-Type", "text/html")
if err := latencyTraceTemplate.ExecuteTemplate(w, "layout.html", data); err != nil {
log.Printf("Template error: %v", err)
http.Error(w, "Template error", http.StatusInternalServerError)
}
}
func handleLatencyTraceAPI(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
ctx, cancel := createAdminContext(r)
defer cancel()
resp, err := gameAdminClient.GetShardokLatencyTraceConfig(ctx, &gameadminpb.GetShardokLatencyTraceConfigRequest{})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
case http.MethodPost:
if err := r.ParseForm(); err != nil {
http.Error(w, "Invalid form data", http.StatusBadRequest)
return
}
enabled := r.FormValue("enabled") == "on" || r.FormValue("enabled") == "true"
ttlSeconds, err := strconv.ParseInt(defaultString(r.FormValue("ttl_seconds"), "0"), 10, 64)
if err != nil {
http.Error(w, "Invalid ttl_seconds", http.StatusBadRequest)
return
}
slowSpanThresholdMs, err := strconv.ParseFloat(defaultString(r.FormValue("slow_span_threshold_ms"), "0"), 64)
if err != nil {
http.Error(w, "Invalid slow_span_threshold_ms", http.StatusBadRequest)
return
}
config := latencyTraceConfigToProto(latencyTraceConfigView{
Enabled: enabled,
GameID: r.FormValue("game_id"),
TTLSeconds: ttlSeconds,
SlowSpanThresholdMs: slowSpanThresholdMs,
})
ctx, cancel := createAdminContext(r)
defer cancel()
resp, err := gameAdminClient.SetShardokLatencyTraceConfig(
ctx,
&gameadminpb.SetShardokLatencyTraceConfigRequest{Config: config},
)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if !resp.Success {
http.Error(w, resp.Error, http.StatusBadGateway)
return
}
http.Redirect(w, r, "/latency-trace", http.StatusSeeOther)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func defaultString(value string, fallback string) string {
if value == "" {
return fallback
}
return value
}
// Player management handlers
func handleAiToHuman(w http.ResponseWriter, r *http.Request, gameIDHex string, gameID int64) {
@@ -2565,15 +2707,15 @@ func handleDeleteUser(w http.ResponseWriter, r *http.Request, userID string) {
// InvitationInfo represents an invitation for display
type InvitationInfo struct {
InvitationCode string
InvitationCodeShort string
Email string
StatusName string
CreatedByUserID string
CreatedByDisplayName string
CreatedAt string
ExpiresAt string
RedeemedByUserID string
InvitationCode string
InvitationCodeShort string
Email string
StatusName string
CreatedByUserID string
CreatedByDisplayName string
CreatedAt string
ExpiresAt string
RedeemedByUserID string
RedeemedByDisplayName string
}
@@ -3567,12 +3709,12 @@ type WhatsNewData struct {
// WhatsNewPageData is the data for the what's-new page template
type WhatsNewPageData struct {
Title string
Entries []WhatsNewEntry
BuildInfo BuildInfo
PreviewMode bool
Title string
Entries []WhatsNewEntry
BuildInfo BuildInfo
PreviewMode bool
PreviewEntry WhatsNewEntry
Error string
Error string
}
// fetchWhatsNewData fetches the what's-new JSON directly from S3.
@@ -0,0 +1,72 @@
{{define "content"}}
<h1>Shardok Latency Trace</h1>
{{if .Error}}
<article class="error">
<strong>Error:</strong> {{.Error}}
</article>
{{end}}
<section>
<h2>Current Config</h2>
<div class="grid">
<article>
<h3>Eagle</h3>
<dl>
<dt>Enabled</dt>
<dd>{{.EagleConfig.Enabled}}</dd>
<dt>Shardok Game ID</dt>
<dd>{{if .EagleConfig.GameID}}{{.EagleConfig.GameID}}{{else}}all games{{end}}</dd>
<dt>TTL Seconds</dt>
<dd>{{.EagleConfig.TTLSeconds}}</dd>
<dt>Slow Span Threshold Ms</dt>
<dd>{{.EagleConfig.SlowSpanThresholdMs}}</dd>
</dl>
</article>
<article>
<h3>Shardok</h3>
<dl>
<dt>Enabled</dt>
<dd>{{.ShardokConfig.Enabled}}</dd>
<dt>Shardok Game ID</dt>
<dd>{{if .ShardokConfig.GameID}}{{.ShardokConfig.GameID}}{{else}}all games{{end}}</dd>
<dt>TTL Seconds</dt>
<dd>{{.ShardokConfig.TTLSeconds}}</dd>
<dt>Slow Span Threshold Ms</dt>
<dd>{{.ShardokConfig.SlowSpanThresholdMs}}</dd>
</dl>
</article>
</div>
</section>
<section>
<h2>Update Config</h2>
<form method="post" action="/api/latency-trace">
<label>
<input type="checkbox" name="enabled" {{if .EagleConfig.Enabled}}checked{{end}}>
Enable tracing
</label>
<label>
Shardok game ID
<input type="text" name="game_id" value="{{.EagleConfig.GameID}}" placeholder="blank traces all games">
</label>
<label>
TTL seconds
<input type="number" name="ttl_seconds" min="0" step="1" value="{{.EagleConfig.TTLSeconds}}">
</label>
<label>
Slow span threshold ms
<input type="number" name="slow_span_threshold_ms" min="0" step="1" value="{{.EagleConfig.SlowSpanThresholdMs}}">
</label>
<button type="submit">Apply</button>
</form>
<form method="post" action="/api/latency-trace">
<input type="hidden" name="enabled" value="false">
<button type="submit" class="secondary">Disable</button>
</form>
</section>
{{end}}
@@ -16,6 +16,7 @@
<li><a href="/accounts">Accounts</a></li>
<li><a href="/settings">Settings</a></li>
<li><a href="/llm-stats">LLM Stats</a></li>
<li><a href="/latency-trace">Latency Trace</a></li>
<li><a href="/maps">Maps</a></li>
<li><a href="/whats-new">What's New</a></li>
<li id="health-status" hx-get="/health/status" hx-trigger="load, every 10s" hx-swap="innerHTML">
@@ -29,6 +29,30 @@ service ShardokInternalInterface {
rpc GetHexMap(HexMapRequest) returns (HexMapResponse) {}
rpc GetHexMapNames(HexMapNamesRequest) returns (HexMapNamesResponse) {}
rpc GetLatencyTraceConfig(GetLatencyTraceConfigRequest) returns (GetLatencyTraceConfigResponse) {}
rpc SetLatencyTraceConfig(SetLatencyTraceConfigRequest) returns (SetLatencyTraceConfigResponse) {}
}
message LatencyTraceConfig {
bool enabled = 1;
string game_id = 2;
int64 ttl_seconds = 3;
double slow_span_threshold_ms = 4;
}
message GetLatencyTraceConfigRequest {}
message GetLatencyTraceConfigResponse {
LatencyTraceConfig config = 1;
}
message SetLatencyTraceConfigRequest {
LatencyTraceConfig config = 1;
}
message SetLatencyTraceConfigResponse {
LatencyTraceConfig config = 1;
}
message GameSubscriptionRequest {
@@ -8,6 +8,7 @@ proto_library(
srcs = ["game_admin.proto"],
visibility = ["//visibility:public"],
deps = [
"//src/main/protobuf/net/eagle0/common:shardok_internal_interface_proto",
"@com_google_protobuf//:wrappers_proto",
],
)
@@ -31,4 +32,5 @@ go_proto_library(
importpath = "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/admin", # keep
proto = ":game_admin_proto", # keep
visibility = ["//visibility:public"],
deps = ["//src/main/protobuf/net/eagle0/common:shardok_internal_interface_go_grpc"],
)
@@ -10,6 +10,7 @@ syntax = "proto3";
package net.eagle0.eagle.admin;
import "google/protobuf/wrappers.proto";
import "src/main/protobuf/net/eagle0/common/shardok_internal_interface.proto";
option java_multiple_files = true;
option java_package = "net.eagle0.eagle.admin";
@@ -72,6 +73,12 @@ service GameAdmin {
// Rewind a game by truncating persisted chunk files (bypasses game loading)
rpc RawRewindGame(RawRewindGameRequest) returns (RawRewindGameResponse) {}
// Get Eagle and Shardok latency tracing configuration.
rpc GetShardokLatencyTraceConfig(GetShardokLatencyTraceConfigRequest) returns (GetShardokLatencyTraceConfigResponse) {}
// Set Eagle and Shardok latency tracing configuration.
rpc SetShardokLatencyTraceConfig(SetShardokLatencyTraceConfigRequest) returns (SetShardokLatencyTraceConfigResponse) {}
}
// GetRunningGames
@@ -180,6 +187,25 @@ message AddSettingsKeyValue {
string value = 2;
}
message GetShardokLatencyTraceConfigRequest {}
message GetShardokLatencyTraceConfigResponse {
.net.eagle0.common.LatencyTraceConfig eagle_config = 1;
.net.eagle0.common.LatencyTraceConfig shardok_config = 2;
string error = 3;
}
message SetShardokLatencyTraceConfigRequest {
.net.eagle0.common.LatencyTraceConfig config = 1;
}
message SetShardokLatencyTraceConfigResponse {
.net.eagle0.common.LatencyTraceConfig eagle_config = 1;
.net.eagle0.common.LatencyTraceConfig shardok_config = 2;
bool success = 3;
string error = 4;
}
// ConvertAiToHuman
message ConvertAiToHumanRequest {
int64 game_id = 1;
@@ -52,6 +52,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_resolution",
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_update",
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_update_receiver",
"//src/main/scala/net/eagle0/eagle/shardok_interface:shardok_interface_grpc_client",
"@maven//:com_thesamet_scalapb_scalapb_json4s_3",
"@maven//:io_sentry_sentry",
"@maven//:org_json4s_json4s_ast_3",
@@ -18,6 +18,7 @@ import net.eagle0.common.llm_integration.{
LlmUsageTracker,
OpenAIChatCompletionsServiceImpl
}
import net.eagle0.common.shardok_internal_interface.LatencyTraceConfig
import net.eagle0.common.SimpleTimedLogger
import net.eagle0.eagle.*
import net.eagle0.eagle.admin.game_admin.*
@@ -27,6 +28,7 @@ import net.eagle0.eagle.library.settings.loaders.SettingsLoader
import net.eagle0.eagle.library.EagleClientException
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.service.persistence.SaveDirectory
import net.eagle0.eagle.shardok_interface.ShardokLatencyTrace
import net.eagle0.eagle.UserId
import org.json4s.*
import org.json4s.native.Serialization.writePretty
@@ -395,6 +397,56 @@ class GameAdminServiceImpl(
end if
}
override def getShardokLatencyTraceConfig(
request: GetShardokLatencyTraceConfigRequest
): Future[GetShardokLatencyTraceConfigResponse] =
gamesManager.shardokClient
.getLatencyTraceConfig()
.map { shardokConfig =>
GetShardokLatencyTraceConfigResponse(
eagleConfig = Some(ShardokLatencyTrace.getConfig),
shardokConfig = Some(shardokConfig)
)
}
.recover {
case e: Exception =>
Sentry.captureException(e)
GetShardokLatencyTraceConfigResponse(
eagleConfig = Some(ShardokLatencyTrace.getConfig),
error = e.getMessage
)
}
override def setShardokLatencyTraceConfig(
request: SetShardokLatencyTraceConfigRequest
): Future[SetShardokLatencyTraceConfigResponse] = {
val requestedConfig = request.config.getOrElse(LatencyTraceConfig())
val eagleConfig = ShardokLatencyTrace.setConfig(requestedConfig)
gamesManager.shardokClient
.setLatencyTraceConfig(requestedConfig)
.map { shardokConfig =>
SimpleTimedLogger.printLogger.logLine(
s"Shardok latency tracing updated: enabled=${requestedConfig.enabled}, " +
s"gameId=${requestedConfig.gameId}, ttlSeconds=${requestedConfig.ttlSeconds}, " +
s"slowSpanThresholdMs=${requestedConfig.slowSpanThresholdMs}"
)
SetShardokLatencyTraceConfigResponse(
eagleConfig = Some(eagleConfig),
shardokConfig = Some(shardokConfig),
success = true
)
}
.recover {
case e: Exception =>
Sentry.captureException(e)
SetShardokLatencyTraceConfigResponse(
eagleConfig = Some(ShardokLatencyTrace.getConfig),
success = false,
error = e.getMessage
)
}
}
override def convertAiToHuman(
request: ConvertAiToHumanRequest
): Future[ConvertAiToHumanResponse] = Future.successful {
@@ -197,6 +197,7 @@ scala_library(
deps = [
":placement_command_scala_proto",
"//src/main/protobuf/net/eagle0/common:game_setup_info_scala_proto",
"//src/main/protobuf/net/eagle0/common:shardok_internal_interface_scala_grpc",
"//src/main/protobuf/net/eagle0/common:tutorial_battle_config_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:shardok_battle_scala_proto",
"//src/main/protobuf/net/eagle0/shardok/common:hex_map_scala_proto",
@@ -484,6 +484,16 @@ class ShardokInterfaceGrpcClient(
.withRetries(_.getHexMapNames(HexMapNamesRequest()))
.map(_.mapNames.toVector)
override def getLatencyTraceConfig(): Future[LatencyTraceConfig] =
retrier
.withRetries(_.getLatencyTraceConfig(GetLatencyTraceConfigRequest()))
.map(_.config.getOrElse(LatencyTraceConfig()))
override def setLatencyTraceConfig(config: LatencyTraceConfig): Future[LatencyTraceConfig] =
retrier
.withRetries(_.setLatencyTraceConfig(SetLatencyTraceConfigRequest(config = Some(config))))
.map(_.config.getOrElse(config))
override def cancelBattlesForGame(eagleGameId: GameId): Unit =
pendingBattles.synchronized {
val toRemove = pendingBattles.collect {
@@ -3,6 +3,7 @@ package net.eagle0.eagle.shardok_interface
import scala.concurrent.Future
import net.eagle0.common.game_setup_info.NewGameRequest
import net.eagle0.common.shardok_internal_interface.LatencyTraceConfig
import net.eagle0.common.tutorial_battle_config.TutorialBattleConfig
import net.eagle0.eagle.{FactionId, GameId, ShardokGameId}
import net.eagle0.eagle.internal.shardok_battle.ShardokBattle
@@ -44,6 +45,8 @@ trait ShardokInterfaceProxy {
def getHexMap(mapName: String): Future[HexMap]
def getHexMapNames(): Future[Seq[String]]
def getLatencyTraceConfig(): Future[LatencyTraceConfig]
def setLatencyTraceConfig(config: LatencyTraceConfig): Future[LatencyTraceConfig]
/** Cancels all pending Shardok battles for the given Eagle game. */
def cancelBattlesForGame(eagleGameId: GameId): Unit
@@ -1,16 +1,63 @@
package net.eagle0.eagle.shardok_interface
import net.eagle0.common.shardok_internal_interface.LatencyTraceConfig
object ShardokLatencyTrace {
private val enabledValues = Set("1", "true", "TRUE", "yes", "YES")
private lazy val enabled: Boolean =
sys.env.get("EAGLE0_SHARDOK_LATENCY_TRACE").exists(enabledValues.contains)
private case class ActiveConfig(
enabled: Boolean,
gameId: String,
ttlSeconds: Long,
slowSpanThresholdMs: Double,
expiresAtNanos: Option[Long]
)
private lazy val gameIdFilter: Option[String] =
sys.env.get("EAGLE0_SHARDOK_LATENCY_GAME_ID")
private def initialConfig: ActiveConfig =
ActiveConfig(
enabled = sys.env.get("EAGLE0_SHARDOK_LATENCY_TRACE").exists(enabledValues.contains),
gameId = sys.env.get("EAGLE0_SHARDOK_LATENCY_GAME_ID").getOrElse(""),
ttlSeconds = 0L,
slowSpanThresholdMs = 0.0,
expiresAtNanos = None
)
private def enabledForGame(gameId: String): Boolean =
enabled && gameIdFilter.forall(filter => filter.isEmpty || filter == gameId)
@volatile private var config: ActiveConfig = initialConfig
private def activeConfig: ActiveConfig = {
val current = config
current.expiresAtNanos match {
case Some(expiresAtNanos) if System.nanoTime() >= expiresAtNanos =>
val expired = current.copy(enabled = false, expiresAtNanos = None)
config = expired
expired
case _ => current
}
}
def setConfig(newConfig: LatencyTraceConfig): LatencyTraceConfig = {
val expiresAtNanos =
if newConfig.enabled && newConfig.ttlSeconds > 0 then Some(System.nanoTime() + newConfig.ttlSeconds * 1000000000L)
else None
config = ActiveConfig(
enabled = newConfig.enabled,
gameId = newConfig.gameId,
ttlSeconds = newConfig.ttlSeconds,
slowSpanThresholdMs = newConfig.slowSpanThresholdMs,
expiresAtNanos = expiresAtNanos
)
getConfig
}
def getConfig: LatencyTraceConfig = {
val current = activeConfig
LatencyTraceConfig(
enabled = current.enabled,
gameId = current.gameId,
ttlSeconds = current.ttlSeconds,
slowSpanThresholdMs = current.slowSpanThresholdMs
)
}
def startNanos(): Long = System.nanoTime()
@@ -20,15 +67,18 @@ object ShardokLatencyTrace {
startedAtNanos: Long,
fields: Map[String, String] = Map.empty
): Unit =
if enabledForGame(gameId) then
val current = activeConfig
if current.enabled && (current.gameId.isEmpty || current.gameId == gameId) then
val durationMs = (System.nanoTime() - startedAtNanos).toDouble / 1000000.0
val base = Map(
"event" -> "shardok_latency",
"component" -> "eagle",
"span" -> span,
"game_id" -> gameId
)
Console.err.println(toJson(base ++ fields, durationMs))
if current.slowSpanThresholdMs <= 0.0 || durationMs >= current.slowSpanThresholdMs then
val base = Map(
"event" -> "shardok_latency",
"component" -> "eagle",
"span" -> span,
"game_id" -> gameId
)
Console.err.println(toJson(base ++ fields, durationMs))
end emit
def trace[A](
span: String,