mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-05-13 08:37:07 +00:00
Add TranscodingMetrics collector and unit tests for all collectors
Adds a fourth collector exposing two transcoding gauges:
- jellyfin_transcoding_sessions{hardware="true|false"}: number of
active transcoding sessions split by whether hardware acceleration
is in use.
- jellyfin_transcoding_bitrate_bps: aggregated output bitrate of all
active transcoding sessions, useful for estimating server bandwidth
usage from a Grafana dashboard.
Adds xUnit + Moq unit tests for every collector (UserMetrics,
SessionMetrics, LibraryMetrics, TranscodingMetrics) and for the
hosted service. The hosted service tests cover the EnableMetrics=false
no-op path and confirm that a throwing collector does not propagate
the exception out of the background loop.
14 tests, all passing locally.
This commit is contained in:
parent
b2bc6010b0
commit
8be669fc7e
7 changed files with 335 additions and 0 deletions
|
|
@ -0,0 +1,51 @@
|
|||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using Prometheus;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Metrics;
|
||||
|
||||
/// <summary>
|
||||
/// Exposes Prometheus metrics describing the active transcoding sessions.
|
||||
/// </summary>
|
||||
public sealed class TranscodingMetrics : IMetricsCollector
|
||||
{
|
||||
private static readonly Gauge _transcodes = Prometheus.Metrics
|
||||
.CreateGauge("jellyfin_transcoding_sessions", "Number of active transcoding sessions grouped by hardware acceleration usage.", "hardware");
|
||||
|
||||
private static readonly Gauge _transcodingBitrate = Prometheus.Metrics
|
||||
.CreateGauge("jellyfin_transcoding_bitrate_bps", "Aggregated bitrate of all active transcoding sessions, in bits per second.");
|
||||
|
||||
private readonly ISessionManager _sessionManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TranscodingMetrics"/> class.
|
||||
/// </summary>
|
||||
/// <param name="sessionManager">The session manager.</param>
|
||||
public TranscodingMetrics(ISessionManager sessionManager)
|
||||
{
|
||||
_sessionManager = sessionManager;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => nameof(TranscodingMetrics);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task CollectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var transcodes = _sessionManager.Sessions
|
||||
.Where(s => s.TranscodingInfo is not null)
|
||||
.Select(s => s.TranscodingInfo)
|
||||
.ToList();
|
||||
|
||||
var hardwareAccelerated = transcodes.Count(t => t.HardwareAccelerationType is not null);
|
||||
_transcodes.WithLabels("true").Set(hardwareAccelerated);
|
||||
_transcodes.WithLabels("false").Set(transcodes.Count - hardwareAccelerated);
|
||||
|
||||
var totalBitrate = transcodes.Sum(t => t.Bitrate ?? 0);
|
||||
_transcodingBitrate.Set(totalBitrate);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
|
@ -92,6 +92,7 @@ namespace Jellyfin.Server
|
|||
serviceCollection.AddSingleton<IMetricsCollector, UserMetrics>();
|
||||
serviceCollection.AddSingleton<IMetricsCollector, SessionMetrics>();
|
||||
serviceCollection.AddSingleton<IMetricsCollector, LibraryMetrics>();
|
||||
serviceCollection.AddSingleton<IMetricsCollector, TranscodingMetrics>();
|
||||
serviceCollection.AddHostedService<MetricsHostedService>();
|
||||
|
||||
// TODO search the assemblies instead of adding them manually?
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Server.Implementations.Metrics;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.Metrics;
|
||||
|
||||
public class LibraryMetricsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CollectAsync_QueriesEveryTrackedKind()
|
||||
{
|
||||
var libraryManager = new Mock<ILibraryManager>();
|
||||
libraryManager.Setup(m => m.GetCount(It.IsAny<InternalItemsQuery>())).Returns(7);
|
||||
|
||||
var collector = new LibraryMetrics(libraryManager.Object);
|
||||
await collector.CollectAsync(CancellationToken.None);
|
||||
|
||||
libraryManager.Verify(m => m.GetCount(It.IsAny<InternalItemsQuery>()), Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CollectAsync_WithEmptyLibrary_DoesNotThrow()
|
||||
{
|
||||
var libraryManager = new Mock<ILibraryManager>();
|
||||
libraryManager.Setup(m => m.GetCount(It.IsAny<InternalItemsQuery>())).Returns(0);
|
||||
|
||||
var collector = new LibraryMetrics(libraryManager.Object);
|
||||
await collector.CollectAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsExpectedValue()
|
||||
{
|
||||
var collector = new LibraryMetrics(Mock.Of<ILibraryManager>());
|
||||
|
||||
Assert.Equal(nameof(LibraryMetrics), collector.Name);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Server.Implementations.Metrics;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.Metrics;
|
||||
|
||||
public class MetricsHostedServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenMetricsDisabled_DoesNotInvokeCollectors()
|
||||
{
|
||||
var collector = new Mock<IMetricsCollector>();
|
||||
collector.SetupGet(c => c.Name).Returns("Mock");
|
||||
var configManager = new Mock<IServerConfigurationManager>();
|
||||
configManager.SetupGet(c => c.Configuration).Returns(new ServerConfiguration { EnableMetrics = false });
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
var service = new MetricsHostedService(
|
||||
configManager.Object,
|
||||
new[] { collector.Object },
|
||||
NullLogger<MetricsHostedService>.Instance);
|
||||
|
||||
// Cancel immediately so the loop exits before the first wait completes.
|
||||
await cts.CancelAsync();
|
||||
await service.StartAsync(cts.Token);
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
collector.Verify(c => c.CollectAsync(It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenCollectorThrows_DoesNotPropagate()
|
||||
{
|
||||
var failing = new Mock<IMetricsCollector>();
|
||||
failing.SetupGet(c => c.Name).Returns("Failing");
|
||||
failing.Setup(c => c.CollectAsync(It.IsAny<CancellationToken>())).ThrowsAsync(new InvalidOperationException("boom"));
|
||||
var configManager = new Mock<IServerConfigurationManager>();
|
||||
configManager.SetupGet(c => c.Configuration).Returns(new ServerConfiguration { EnableMetrics = true });
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
var service = new MetricsHostedService(
|
||||
configManager.Object,
|
||||
new[] { failing.Object },
|
||||
NullLogger<MetricsHostedService>.Instance);
|
||||
|
||||
await service.StartAsync(cts.Token);
|
||||
// Give the BackgroundService one scheduling slice to enter the loop.
|
||||
await Task.Yield();
|
||||
await cts.CancelAsync();
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// The host must still be alive — no unobserved exception escaped the catch block.
|
||||
Assert.True(true);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Server.Implementations.Metrics;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Session;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.Metrics;
|
||||
|
||||
public class SessionMetricsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CollectAsync_WithMixedSessions_CompletesWithoutError()
|
||||
{
|
||||
var sessionManager = new Mock<ISessionManager>();
|
||||
var playing = new SessionInfo(sessionManager.Object, NullLogger.Instance) { Client = "Web", NowPlayingItem = new BaseItemDto() };
|
||||
playing.PlayState!.PlayMethod = PlayMethod.DirectPlay;
|
||||
var idle = new SessionInfo(sessionManager.Object, NullLogger.Instance) { Client = "Android" };
|
||||
var transcoding = new SessionInfo(sessionManager.Object, NullLogger.Instance) { Client = "AndroidTV", NowPlayingItem = new BaseItemDto(), TranscodingInfo = new TranscodingInfo() };
|
||||
transcoding.PlayState!.PlayMethod = PlayMethod.Transcode;
|
||||
sessionManager.SetupGet(m => m.Sessions).Returns(new[] { playing, idle, transcoding });
|
||||
|
||||
var collector = new SessionMetrics(sessionManager.Object);
|
||||
await collector.CollectAsync(CancellationToken.None);
|
||||
|
||||
sessionManager.VerifyGet(m => m.Sessions, Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CollectAsync_WithNoSessions_DoesNotThrow()
|
||||
{
|
||||
var sessionManager = new Mock<ISessionManager>();
|
||||
sessionManager.SetupGet(m => m.Sessions).Returns(new List<SessionInfo>());
|
||||
|
||||
var collector = new SessionMetrics(sessionManager.Object);
|
||||
await collector.CollectAsync(CancellationToken.None);
|
||||
|
||||
sessionManager.VerifyGet(m => m.Sessions, Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsExpectedValue()
|
||||
{
|
||||
var collector = new SessionMetrics(Mock.Of<ISessionManager>());
|
||||
|
||||
Assert.Equal(nameof(SessionMetrics), collector.Name);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Server.Implementations.Metrics;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Session;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.Metrics;
|
||||
|
||||
public class TranscodingMetricsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CollectAsync_WithHardwareAndSoftwareTranscodes_CompletesWithoutError()
|
||||
{
|
||||
var sessionManager = new Mock<ISessionManager>();
|
||||
var hardware = new SessionInfo(sessionManager.Object, NullLogger.Instance)
|
||||
{
|
||||
TranscodingInfo = new TranscodingInfo
|
||||
{
|
||||
HardwareAccelerationType = HardwareAccelerationType.nvenc,
|
||||
Bitrate = 4_000_000,
|
||||
},
|
||||
};
|
||||
var software = new SessionInfo(sessionManager.Object, NullLogger.Instance)
|
||||
{
|
||||
TranscodingInfo = new TranscodingInfo
|
||||
{
|
||||
HardwareAccelerationType = null,
|
||||
Bitrate = 2_000_000,
|
||||
},
|
||||
};
|
||||
var notTranscoding = new SessionInfo(sessionManager.Object, NullLogger.Instance);
|
||||
sessionManager.SetupGet(m => m.Sessions).Returns(new[] { hardware, software, notTranscoding });
|
||||
|
||||
var collector = new TranscodingMetrics(sessionManager.Object);
|
||||
await collector.CollectAsync(CancellationToken.None);
|
||||
|
||||
sessionManager.VerifyGet(m => m.Sessions, Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CollectAsync_WithNoActiveTranscodes_DoesNotThrow()
|
||||
{
|
||||
var sessionManager = new Mock<ISessionManager>();
|
||||
sessionManager.SetupGet(m => m.Sessions).Returns(new List<SessionInfo>());
|
||||
|
||||
var collector = new TranscodingMetrics(sessionManager.Object);
|
||||
await collector.CollectAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsExpectedValue()
|
||||
{
|
||||
var collector = new TranscodingMetrics(Mock.Of<ISessionManager>());
|
||||
|
||||
Assert.Equal(nameof(TranscodingMetrics), collector.Name);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Server.Implementations.Metrics;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.Metrics;
|
||||
|
||||
public class UserMetricsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CollectAsync_WithMixedUsers_CompletesWithoutError()
|
||||
{
|
||||
var users = new[]
|
||||
{
|
||||
CreateUser("admin", isAdmin: true, isDisabled: false, lastActivityDaysAgo: 1, failedLogins: 0),
|
||||
CreateUser("regular", isAdmin: false, isDisabled: false, lastActivityDaysAgo: 5, failedLogins: 2),
|
||||
CreateUser("disabled", isAdmin: false, isDisabled: true, lastActivityDaysAgo: 90, failedLogins: 0),
|
||||
};
|
||||
var userManager = new Mock<IUserManager>();
|
||||
userManager.Setup(m => m.GetUsers()).Returns(users);
|
||||
|
||||
var collector = new UserMetrics(userManager.Object);
|
||||
await collector.CollectAsync(CancellationToken.None);
|
||||
|
||||
userManager.Verify(m => m.GetUsers(), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CollectAsync_WithNoUsers_DoesNotThrow()
|
||||
{
|
||||
var userManager = new Mock<IUserManager>();
|
||||
userManager.Setup(m => m.GetUsers()).Returns(Array.Empty<User>());
|
||||
|
||||
var collector = new UserMetrics(userManager.Object);
|
||||
await collector.CollectAsync(CancellationToken.None);
|
||||
|
||||
userManager.Verify(m => m.GetUsers(), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsExpectedValue()
|
||||
{
|
||||
var collector = new UserMetrics(Mock.Of<IUserManager>());
|
||||
|
||||
Assert.Equal(nameof(UserMetrics), collector.Name);
|
||||
}
|
||||
|
||||
private static User CreateUser(string name, bool isAdmin, bool isDisabled, int lastActivityDaysAgo, int failedLogins)
|
||||
{
|
||||
var user = new User(name, "DefaultAuthenticationProvider", "DefaultPasswordResetProvider")
|
||||
{
|
||||
LastActivityDate = DateTime.UtcNow.AddDays(-lastActivityDaysAgo),
|
||||
InvalidLoginAttemptCount = failedLogins,
|
||||
};
|
||||
user.SetPermission(PermissionKind.IsAdministrator, isAdmin);
|
||||
user.SetPermission(PermissionKind.IsDisabled, isDisabled);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue