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:
Thomas05000005 2026-05-09 11:12:12 +02:00
parent b2bc6010b0
commit 8be669fc7e
7 changed files with 335 additions and 0 deletions

View file

@ -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;
}
}

View file

@ -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?

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}