mirror of
https://github.com/jamenai/opensim-addon-modules-exp.git
synced 2026-07-28 13:47:12 +00:00
307 lines
11 KiB
C#
307 lines
11 KiB
C#
using System;
|
|
using System.Globalization;
|
|
using System.Net;
|
|
using System.Reflection;
|
|
using System.Text;
|
|
using System.Threading;
|
|
using log4net;
|
|
using Mono.Addins;
|
|
using Nini.Config;
|
|
using OpenSim.Framework;
|
|
using OpenSim.Framework.Console;
|
|
using OpenSim.Region.Framework.Interfaces;
|
|
using OpenSim.Region.Framework.Scenes;
|
|
|
|
namespace OpenSimRegionWarmupHealthGuard.Modules
|
|
{
|
|
[Extension(Path = "/OpenSim/RegionModules", NodeName = "RegionModule", Id = "RegionMetricsExporter")]
|
|
public class RegionMetricsExporterModule : INonSharedRegionModule
|
|
{
|
|
private static readonly ILog Log = LogManager.GetLogger(MethodBase.GetCurrentMethod().DeclaringType);
|
|
|
|
private Scene _scene;
|
|
private bool _enabled;
|
|
|
|
// Config
|
|
private int _httpPort = 9109;
|
|
private string _metricsPrefix = "opensim_region_";
|
|
private string _bindAddress = "127.0.0.1"; // default loopback for safety
|
|
private bool _includeRegionLabel = true;
|
|
|
|
// State
|
|
private HttpListener _listener;
|
|
private Thread _httpThread;
|
|
private volatile bool _httpRunning;
|
|
private readonly object _lastLock = new object();
|
|
private HealthSample _lastSample; // last health sample for this region
|
|
|
|
// Bus
|
|
private IRegionHealthBus _bus;
|
|
|
|
public string Name => "RegionMetricsExporter";
|
|
public Type ReplaceableInterface => null;
|
|
|
|
public void Initialise(IConfigSource source)
|
|
{
|
|
var modules = source.Configs["Modules"];
|
|
if (modules == null) return;
|
|
|
|
var status = modules.GetString("RegionMetricsExporter", string.Empty);
|
|
if (!string.Equals(status, "enabled", StringComparison.OrdinalIgnoreCase)) return;
|
|
|
|
var cfg = source.Configs["RegionMetricsExporter"] ?? source.AddConfig("RegionMetricsExporter");
|
|
|
|
_httpPort = Math.Max(1, cfg.GetInt("HttpPort", _httpPort));
|
|
_metricsPrefix = cfg.GetString("MetricsPrefix", _metricsPrefix);
|
|
_bindAddress = cfg.GetString("BindAddress", _bindAddress);
|
|
_includeRegionLabel = cfg.GetBoolean("IncludeRegionLabel", _includeRegionLabel);
|
|
|
|
_enabled = true;
|
|
Log.Info($"[METRICS] RegionMetricsExporter enabled (port={_httpPort}, bind={_bindAddress})");
|
|
}
|
|
|
|
public void AddRegion(Scene scene)
|
|
{
|
|
if (!_enabled) return;
|
|
_scene = scene;
|
|
|
|
// Discover health bus (provided by RegionHealthMonitor)
|
|
_bus = _scene.RequestModuleInterface<IRegionHealthBus>();
|
|
if (_bus == null)
|
|
{
|
|
Log.Warn("[METRICS] No IRegionHealthBus found. Metrics will remain empty until RegionHealthMonitor is enabled.");
|
|
}
|
|
else
|
|
{
|
|
_bus.OnSample += OnHealthSample;
|
|
}
|
|
|
|
_scene.RegisterModuleInterface(this);
|
|
}
|
|
|
|
public void RegionLoaded(Scene scene)
|
|
{
|
|
if (!_enabled) return;
|
|
|
|
// Start HTTP listener per region module instance (separate port or one shared port per process recommended)
|
|
// Here: single-port per region instance. In multi-region processes, use different ports per region.
|
|
StartHttp();
|
|
|
|
MainConsole.Instance.Commands.AddCommand("Region", false, "metrics",
|
|
"metrics",
|
|
"Metrics exporter commands. Usage: metrics status",
|
|
HandleConsole);
|
|
|
|
Log.Info($"[METRICS] Ready for region '{_scene.RegionInfo.RegionName}'. Endpoint: http://{_bindAddress}:{_httpPort}/metrics");
|
|
}
|
|
|
|
public void RemoveRegion(Scene scene)
|
|
{
|
|
if (!_enabled) return;
|
|
|
|
StopHttp();
|
|
|
|
if (_bus != null)
|
|
_bus.OnSample -= OnHealthSample;
|
|
|
|
_scene.UnregisterModuleInterface(this);
|
|
_scene = null;
|
|
}
|
|
|
|
public void Close()
|
|
{
|
|
StopHttp();
|
|
}
|
|
|
|
private void HandleConsole(string module, string[] args)
|
|
{
|
|
if (_scene == null) { MainConsole.Instance.Output("[METRICS] No active scene."); return; }
|
|
if (args.Length < 2)
|
|
{
|
|
MainConsole.Instance.Output("metrics status - show exporter status");
|
|
return;
|
|
}
|
|
|
|
switch (args[1].ToLowerInvariant())
|
|
{
|
|
case "status":
|
|
MainConsole.Instance.Output($"[METRICS] Endpoint: http://{_bindAddress}:{_httpPort}/metrics Running={_httpRunning}");
|
|
break;
|
|
default:
|
|
MainConsole.Instance.Output("metrics status");
|
|
break;
|
|
}
|
|
}
|
|
|
|
private void OnHealthSample(HealthSample s)
|
|
{
|
|
lock (_lastLock)
|
|
{
|
|
_lastSample = s;
|
|
}
|
|
}
|
|
|
|
private void StartHttp()
|
|
{
|
|
try
|
|
{
|
|
if (_httpRunning) return;
|
|
|
|
_listener = new HttpListener();
|
|
_listener.Prefixes.Add($"http://{_bindAddress}:{_httpPort}/metrics/");
|
|
// Allow also without trailing slash
|
|
_listener.Prefixes.Add($"http://{_bindAddress}:{_httpPort}/metrics");
|
|
_listener.Start();
|
|
|
|
_httpRunning = true;
|
|
_httpThread = new Thread(HttpLoop) { IsBackground = true, Name = "RegionMetricsExporter-HTTP" };
|
|
_httpThread.Start();
|
|
|
|
Log.Info($"[METRICS] HTTP metrics endpoint started on http://{_bindAddress}:{_httpPort}/metrics");
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Log.Error($"[METRICS] Failed to start HTTP listener: {e.Message}");
|
|
_httpRunning = false;
|
|
try { _listener?.Stop(); } catch { }
|
|
_listener = null;
|
|
}
|
|
}
|
|
|
|
private void StopHttp()
|
|
{
|
|
try
|
|
{
|
|
_httpRunning = false;
|
|
try { _listener?.Stop(); } catch { }
|
|
try { _listener?.Close(); } catch { }
|
|
_listener = null;
|
|
}
|
|
catch { }
|
|
|
|
if (_httpThread != null)
|
|
{
|
|
try { _httpThread.Join(1000); } catch { }
|
|
_httpThread = null;
|
|
}
|
|
}
|
|
|
|
private void HttpLoop()
|
|
{
|
|
while (_httpRunning && _listener != null)
|
|
{
|
|
HttpListenerContext ctx = null;
|
|
try
|
|
{
|
|
ctx = _listener.GetContext();
|
|
}
|
|
catch
|
|
{
|
|
if (!_httpRunning) break;
|
|
continue;
|
|
}
|
|
|
|
ThreadPool.QueueUserWorkItem(_ => HandleRequest(ctx));
|
|
}
|
|
}
|
|
|
|
private void HandleRequest(HttpListenerContext ctx)
|
|
{
|
|
try
|
|
{
|
|
if (ctx.Request.HttpMethod != "GET")
|
|
{
|
|
ctx.Response.StatusCode = 405;
|
|
ctx.Response.Close();
|
|
return;
|
|
}
|
|
|
|
if (!ctx.Request.Url.AbsolutePath.StartsWith("/metrics", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
ctx.Response.StatusCode = 404;
|
|
ctx.Response.Close();
|
|
return;
|
|
}
|
|
|
|
var text = BuildPrometheusText();
|
|
var buffer = Encoding.UTF8.GetBytes(text);
|
|
ctx.Response.StatusCode = 200;
|
|
ctx.Response.ContentType = "text/plain; version=0.0.4; charset=utf-8";
|
|
ctx.Response.ContentEncoding = Encoding.UTF8;
|
|
ctx.Response.ContentLength64 = buffer.Length;
|
|
ctx.Response.OutputStream.Write(buffer, 0, buffer.Length);
|
|
ctx.Response.OutputStream.Close();
|
|
}
|
|
catch
|
|
{
|
|
try { ctx.Response.StatusCode = 500; ctx.Response.Close(); } catch { }
|
|
}
|
|
}
|
|
|
|
private string BuildPrometheusText()
|
|
{
|
|
HealthSample s;
|
|
lock (_lastLock)
|
|
{
|
|
s = _lastSample;
|
|
}
|
|
|
|
var region = _scene?.RegionInfo?.RegionName ?? "unknown";
|
|
var sb = new StringBuilder(512);
|
|
var prefix = _metricsPrefix;
|
|
|
|
// HELP/TYPE
|
|
AppendHelpType(sb, prefix + "agents", "Number of agents (root + child) in region", "gauge");
|
|
AppendHelpType(sb, prefix + "prims", "Total number of prims/objects in region", "gauge");
|
|
AppendHelpType(sb, prefix + "script_time_ms", "Script time (ms)", "gauge");
|
|
AppendHelpType(sb, prefix + "physics_time_ms", "Physics time (ms)", "gauge");
|
|
AppendHelpType(sb, prefix + "net_time_ms", "Network time (ms)", "gauge");
|
|
AppendHelpType(sb, prefix + "script_errors", "Script error count", "gauge");
|
|
AppendHelpType(sb, prefix + "uptime_seconds", "Region uptime (seconds)", "gauge");
|
|
AppendHelpType(sb, prefix + "sample_timestamp_seconds", "Last sample timestamp (unix seconds)", "gauge");
|
|
|
|
var labels = _includeRegionLabel ? $"{{region=\"{EscapeLabel(region)}\"}}" : "";
|
|
|
|
if (s == null)
|
|
{
|
|
// no sample yet: export zeros
|
|
sb.AppendLine($"{prefix}agents{labels} 0");
|
|
sb.AppendLine($"{prefix}prims{labels} 0");
|
|
sb.AppendLine($"{prefix}script_time_ms{labels} 0");
|
|
sb.AppendLine($"{prefix}physics_time_ms{labels} 0");
|
|
sb.AppendLine($"{prefix}net_time_ms{labels} 0");
|
|
sb.AppendLine($"{prefix}script_errors{labels} 0");
|
|
sb.AppendLine($"{prefix}uptime_seconds{labels} 0");
|
|
sb.AppendLine($"{prefix}sample_timestamp_seconds{labels} 0");
|
|
return sb.ToString();
|
|
}
|
|
|
|
// Invariant culture for dots
|
|
var ci = CultureInfo.InvariantCulture;
|
|
|
|
sb.AppendLine($"{prefix}agents{labels} {s.Agents.ToString(ci)}");
|
|
sb.AppendLine($"{prefix}prims{labels} {s.Prims.ToString(ci)}");
|
|
sb.AppendLine($"{prefix}script_time_ms{labels} {s.ScriptTimeMs.ToString(ci)}");
|
|
sb.AppendLine($"{prefix}physics_time_ms{labels} {s.PhysicsTimeMs.ToString(ci)}");
|
|
sb.AppendLine($"{prefix}net_time_ms{labels} {s.NetTimeMs.ToString(ci)}");
|
|
sb.AppendLine($"{prefix}script_errors{labels} {s.ScriptErrors.ToString(ci)}");
|
|
sb.AppendLine($"{prefix}uptime_seconds{labels} {((long)s.Uptime.TotalSeconds).ToString(ci)}");
|
|
var unix = new DateTimeOffset(s.TimestampUtc).ToUnixTimeSeconds();
|
|
sb.AppendLine($"{prefix}sample_timestamp_seconds{labels} {unix.ToString(ci)}");
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
private static void AppendHelpType(StringBuilder sb, string name, string help, string type)
|
|
{
|
|
sb.AppendLine($"# HELP {name} {help}");
|
|
sb.AppendLine($"# TYPE {name} {type}");
|
|
}
|
|
|
|
private static string EscapeLabel(string value)
|
|
{
|
|
if (string.IsNullOrEmpty(value)) return "";
|
|
return value.Replace("\\", "\\\\").Replace("\"", "\\\"");
|
|
}
|
|
}
|
|
} |