// ---------------------------------------------------------------------------------------------- // _ _ _ ____ _ _____ // / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___ // / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \ // / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | | // /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_| // ---------------------------------------------------------------------------------------------- // | // Copyright 2015-2024 Ɓukasz "JustArchi" Domeradzki // Contact: JustArchi@JustArchi.net // | // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // | // http://www.apache.org/licenses/LICENSE-2.0 // | // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. using System; using System.Collections.Concurrent; using System.Collections.Frozen; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Composition; using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Metrics; using System.Linq; using System.Text.Json.Serialization; using System.Threading.Tasks; using ArchiSteamFarm.Core; using ArchiSteamFarm.IPC.Integration; using ArchiSteamFarm.Plugins; using ArchiSteamFarm.Plugins.Interfaces; using ArchiSteamFarm.Steam; using ArchiSteamFarm.Steam.Exchange; using ArchiSteamFarm.Storage; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using OpenTelemetry.Metrics; using SteamKit2; namespace ArchiSteamFarm.OfficialPlugins.Monitoring; [Export(typeof(IPlugin))] [SuppressMessage("ReSharper", "MemberCanBeFileLocal")] internal sealed class MonitoringPlugin : OfficialPlugin, IDisposable, IOfficialGitHubPluginUpdates, IWebInterface, IWebServiceProvider, IBotTradeOfferResults { private const string MeterName = SharedInfo.AssemblyName; private const string MetricNamePrefix = "asf"; private const string UnknownLabelValueFallback = "unknown"; private static readonly Measurement BuildInfo = new( 1, new KeyValuePair(TagNames.Version, SharedInfo.Version.ToString()), new KeyValuePair(TagNames.Variant, Core.BuildInfo.Variant) ); private static readonly Measurement RuntimeInfo = new( 1, new KeyValuePair(TagNames.Framework, OS.Framework ?? UnknownLabelValueFallback), new KeyValuePair(TagNames.Runtime, OS.Runtime ?? UnknownLabelValueFallback), new KeyValuePair(TagNames.OS, OS.Description ?? UnknownLabelValueFallback) ); private static bool Enabled => ASF.GlobalConfig?.IPC ?? GlobalConfig.DefaultIPC; private static FrozenSet>? PluginMeasurements; [JsonInclude] [Required] public override string Name => nameof(MonitoringPlugin); public string RepositoryName => SharedInfo.GithubRepo; [JsonInclude] [Required] public override Version Version => typeof(MonitoringPlugin).Assembly.GetName().Version ?? throw new InvalidOperationException(nameof(Version)); private readonly ConcurrentDictionary TradeStatistics = new(); private Meter? Meter; public void Dispose() => Meter?.Dispose(); public Task OnBotTradeOfferResults(Bot bot, IReadOnlyCollection tradeResults) { ArgumentNullException.ThrowIfNull(bot); ArgumentNullException.ThrowIfNull(tradeResults); TradeStatistics statistics = TradeStatistics.GetOrAdd(bot, static _ => new TradeStatistics()); foreach (ParseTradeResult result in tradeResults) { statistics.Include(result); } return Task.CompletedTask; } public void OnConfiguringEndpoints(IApplicationBuilder app) { ArgumentNullException.ThrowIfNull(app); if (!Enabled) { return; } app.UseEndpoints(static builder => builder.MapPrometheusScrapingEndpoint()); } public void OnConfiguringServices(IServiceCollection services) { ArgumentNullException.ThrowIfNull(services); if (!Enabled) { return; } InitializeMeter(); services.AddOpenTelemetry().WithMetrics( builder => { builder.AddPrometheusExporter(static config => config.ScrapeEndpointPath = "/Api/metrics"); builder.AddRuntimeInstrumentation(); builder.AddAspNetCoreInstrumentation(); builder.AddHttpClientInstrumentation(); builder.AddMeter(Meter.Name); } ); } public override Task OnLoaded() => Task.CompletedTask; [MemberNotNull(nameof(Meter))] private void InitializeMeter() { if (Meter != null) { return; } PluginMeasurements = new HashSet>(3) { new(PluginsCore.ActivePlugins.Count), new(PluginsCore.ActivePlugins.Count(static plugin => plugin is OfficialPlugin), new KeyValuePair(TagNames.PluginType, "official")), new(PluginsCore.ActivePlugins.Count(static plugin => plugin is not OfficialPlugin), new KeyValuePair(TagNames.PluginType, "custom")) }.ToFrozenSet(); Meter = new Meter(MeterName, Version.ToString()); Meter.CreateObservableGauge( $"{MetricNamePrefix}_build_info", static () => BuildInfo, description: "Build information about ASF in form of label values" ); Meter.CreateObservableGauge( $"{MetricNamePrefix}_runtime_info", static () => RuntimeInfo, description: "Runtime information about ASF in form of label values" ); Meter.CreateObservableGauge( $"{MetricNamePrefix}_ipc_banned_ips", static () => ApiAuthenticationMiddleware.GetCurrentlyBannedIPs().Count(), description: "Number of IP addresses currently banned by ASFs IPC module" ); Meter.CreateObservableGauge( $"{MetricNamePrefix}_active_plugins", static () => PluginMeasurements, description: "Number of plugins currently loaded in ASF" ); Meter.CreateObservableGauge( $"{MetricNamePrefix}_bots", static () => { IEnumerable bots = Bot.Bots?.Values ?? []; int onlineCount = 0; int offlineCount = 0; int farmingCount = 0; foreach (Bot bot in bots) { if (bot.IsConnectedAndLoggedOn) { onlineCount++; } else { offlineCount++; } if (bot.CardsFarmer.NowFarming) { farmingCount++; } } return new HashSet>(4) { new(onlineCount + offlineCount, new KeyValuePair(TagNames.BotState, "configured")), new(onlineCount, new KeyValuePair(TagNames.BotState, "online")), new(offlineCount, new KeyValuePair(TagNames.BotState, "offline")), new(farmingCount, new KeyValuePair(TagNames.BotState, "farming")) }; }, description: "Number of bots that are currently loaded in ASF" ); Meter.CreateObservableGauge( $"{MetricNamePrefix}_bot_friends", static () => { IEnumerable bots = Bot.Bots?.Values ?? []; return bots.Where(static bot => bot.IsConnectedAndLoggedOn).Select(static bot => new Measurement(bot.SteamFriends.GetFriendCount(), new KeyValuePair(TagNames.BotName, bot.BotName), new KeyValuePair(TagNames.SteamID, bot.SteamID))); }, description: "Number of friends each bot has on Steam" ); Meter.CreateObservableGauge( $"{MetricNamePrefix}_bot_clans", static () => { IEnumerable bots = Bot.Bots?.Values ?? []; return bots.Where(static bot => bot.IsConnectedAndLoggedOn).Select(static bot => new Measurement(bot.SteamFriends.GetClanCount(), new KeyValuePair(TagNames.BotName, bot.BotName), new KeyValuePair(TagNames.SteamID, bot.SteamID))); }, description: "Number of Steam groups each bot is in" ); // Keep in mind that we use a unit here and the unit needs to be a suffix to the name Meter.CreateObservableGauge( $"{MetricNamePrefix}_bot_farming_time_remaining_{Units.Minutes}", static () => { IEnumerable bots = Bot.Bots?.Values ?? []; return bots.Where(static bot => bot.IsConnectedAndLoggedOn).Select(static bot => new Measurement(bot.CardsFarmer.TimeRemaining.TotalMinutes, new KeyValuePair(TagNames.BotName, bot.BotName), new KeyValuePair(TagNames.SteamID, bot.SteamID))); }, Units.Minutes, "Approximate number of minutes remaining until each bot has finished farming all cards" ); Meter.CreateObservableGauge( $"{MetricNamePrefix}_bot_heartbeat_failures", static () => { IEnumerable bots = Bot.Bots?.Values ?? []; return bots.Select(static bot => new Measurement(bot.HeartBeatFailures, new KeyValuePair(TagNames.BotName, bot.BotName), new KeyValuePair(TagNames.SteamID, bot.SteamID))); }, description: "Number of times a bot has failed to reach Steam servers" ); Meter.CreateObservableGauge( $"{MetricNamePrefix}_bot_wallet_balance", static () => { IEnumerable bots = Bot.Bots?.Values ?? []; return bots.Where(static bot => bot.WalletCurrency != ECurrencyCode.Invalid).Select(static bot => new Measurement(bot.WalletBalance, new KeyValuePair(TagNames.BotName, bot.BotName), new KeyValuePair(TagNames.SteamID, bot.SteamID), new KeyValuePair(TagNames.CurrencyCode, bot.WalletCurrency.ToString()))); }, description: "Current Steam wallet balance of each bot" ); Meter.CreateObservableGauge( $"{MetricNamePrefix}_bot_bgr_keys_remaining", static () => { IEnumerable bots = Bot.Bots?.Values ?? []; return bots.Select(static bot => new Measurement((int) bot.GamesToRedeemInBackgroundCount, new KeyValuePair(TagNames.BotName, bot.BotName), new KeyValuePair(TagNames.SteamID, bot.SteamID))); }, description: "Remaining games to redeem in background per bot" ); Meter.CreateObservableCounter( $"{MetricNamePrefix}_bot_trades", () => TradeStatistics.SelectMany, Measurement>( static kv => [ new Measurement( kv.Value.AcceptedOffers, new KeyValuePair(TagNames.BotName, kv.Key.BotName), new KeyValuePair(TagNames.SteamID, kv.Key.SteamID), new KeyValuePair(TagNames.TradeOfferResult, "accepted") ), new Measurement( kv.Value.RejectedOffers, new KeyValuePair(TagNames.BotName, kv.Key.BotName), new KeyValuePair(TagNames.SteamID, kv.Key.SteamID), new KeyValuePair(TagNames.TradeOfferResult, "rejected") ), new Measurement( kv.Value.IgnoredOffers, new KeyValuePair(TagNames.BotName, kv.Key.BotName), new KeyValuePair(TagNames.SteamID, kv.Key.SteamID), new KeyValuePair(TagNames.TradeOfferResult, "ignored") ), new Measurement( kv.Value.BlacklistedOffers, new KeyValuePair(TagNames.BotName, kv.Key.BotName), new KeyValuePair(TagNames.SteamID, kv.Key.SteamID), new KeyValuePair(TagNames.TradeOfferResult, "blacklisted") ), new Measurement( kv.Value.ConfirmedOffers, new KeyValuePair(TagNames.BotName, kv.Key.BotName), new KeyValuePair(TagNames.SteamID, kv.Key.SteamID), new KeyValuePair(TagNames.TradeOfferResult, "confirmed") ) ] ), description: "Trade offers per bot and action taken by ASF" ); Meter.CreateObservableCounter( $"{MetricNamePrefix}_bot_items_given", () => TradeStatistics.Select(static kv => new Measurement(kv.Value.ItemsGiven, new KeyValuePair(TagNames.BotName, kv.Key.BotName), new KeyValuePair(TagNames.SteamID, kv.Key.SteamID))), description: "Items given per bot" ); Meter.CreateObservableCounter( $"{MetricNamePrefix}_bot_items_received", () => TradeStatistics.Select(static kv => new Measurement(kv.Value.ItemsReceived, new KeyValuePair(TagNames.BotName, kv.Key.BotName), new KeyValuePair(TagNames.SteamID, kv.Key.SteamID))), description: "Items received per bot" ); } }