Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/Clients/IStatsClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System.Threading.Tasks;
using StreamChat.Models;

namespace StreamChat.Clients
{
/// <summary>
/// A client that can be used to query team-level usage statistics.
/// </summary>
public interface IStatsClient
{
/// <summary>
/// <para>Queries team-level usage statistics from the warehouse database.</para>
/// Returns all 16 metrics grouped by team with cursor-based pagination.
/// This endpoint is server-side only.
/// <para>Date Range Options (mutually exclusive):</para>
/// <list type="bullet">
/// <item>Use 'month' parameter (YYYY-MM format) for monthly aggregated values</item>
/// <item>Use 'start_date'/'end_date' parameters (YYYY-MM-DD format) for daily breakdown</item>
/// <item>If neither provided, defaults to current month (monthly mode)</item>
/// </list>
/// </summary>
Task<QueryTeamUsageStatsResponse> QueryTeamUsageStatsAsync(QueryTeamUsageStatsOptions options = null);
}
}
5 changes: 5 additions & 0 deletions src/Clients/IStreamClientFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,5 +101,10 @@ public interface IStreamClientFactory
/// </summary>
/// <remarks>https://getstream.io/chat/docs/dotnet-csharp/threads/?language=csharp#filtering-and-sorting-threads</remarks>
IThreadClient GetThreadClient();

/// <summary>
/// Returns an <see cref="IStatsClient"/> instance. The returned client can be used as a singleton in your application.
/// </summary>
IStatsClient GetStatsClient();
}
}
20 changes: 20 additions & 0 deletions src/Clients/StatsClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System.Net;
using System.Threading.Tasks;
using StreamChat.Models;
using StreamChat.Rest;

namespace StreamChat.Clients
{
public class StatsClient : ClientBase, IStatsClient
{
internal StatsClient(IRestClient client) : base(client)
{
}

public async Task<QueryTeamUsageStatsResponse> QueryTeamUsageStatsAsync(QueryTeamUsageStatsOptions options = null)
=> await ExecuteRequestAsync<QueryTeamUsageStatsResponse>("stats/team_usage",
HttpMethod.POST,
HttpStatusCode.Created,
options ?? new QueryTeamUsageStatsOptions());
}
}
3 changes: 3 additions & 0 deletions src/Clients/StreamClientFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public class StreamClientFactory : IStreamClientFactory
private readonly ITaskClient _taskClient;
private readonly IModerationClient _moderationClient;
private readonly IThreadClient _threadClient;
private readonly IStatsClient _statsClient;

/// <summary>
/// Initializes a new instance of the <see cref="StreamClientFactory"/> class.
Expand Down Expand Up @@ -94,6 +95,7 @@ public StreamClientFactory(string apiKey, string apiSecret, Action<ClientOptions
_userClient = new UserClient(restClient, jwtGeneratorClient, apiSecret);
_moderationClient = new ModerationClient(restClient);
_threadClient = new ThreadClient(restClient);
_statsClient = new StatsClient(restClient);
}

public IAppClient GetAppClient() => _appClient;
Expand All @@ -112,5 +114,6 @@ public StreamClientFactory(string apiKey, string apiSecret, Action<ClientOptions
public IUserClient GetUserClient() => _userClient;
public IModerationClient GetModerationClient() => _moderationClient;
public IThreadClient GetThreadClient() => _threadClient;
public IStatsClient GetStatsClient() => _statsClient;
}
}
3 changes: 3 additions & 0 deletions src/Models/Import.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ public enum ImportState
{
None,

[EnumMember(Value = "queued")]
Queued,

[EnumMember(Value = "uploaded")]
Uploaded,

Expand Down
160 changes: 160 additions & 0 deletions src/Models/TeamUsageStats.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
using System.Collections.Generic;
using Newtonsoft.Json;

namespace StreamChat.Models
{
/// <summary>
/// Represents a metric value for a specific date.
/// </summary>
public class DailyValue
{
/// <summary>Date in YYYY-MM-DD format.</summary>
[JsonProperty("date")]
public string Date { get; set; }

/// <summary>Metric value for this date.</summary>
[JsonProperty("value")]
public long Value { get; set; }
}

/// <summary>
/// Statistics for a single metric with optional daily breakdown.
/// </summary>
public class MetricStats
{
/// <summary>Per-day values (only present in daily mode).</summary>
[JsonProperty("daily")]
public List<DailyValue> Daily { get; set; }

/// <summary>Aggregated total value.</summary>
[JsonProperty("total")]
public long Total { get; set; }
}

/// <summary>
/// Team-level usage statistics for multi-tenant apps.
/// </summary>
public class TeamUsageStats
{
/// <summary>Team identifier (empty string for users not assigned to any team).</summary>
[JsonProperty("team")]
public string Team { get; set; }

// Daily activity metrics (total = SUM of daily values)

/// <summary>Daily active users.</summary>
[JsonProperty("users_daily")]
public MetricStats UsersDaily { get; set; }

/// <summary>Daily messages sent.</summary>
[JsonProperty("messages_daily")]
public MetricStats MessagesDaily { get; set; }

/// <summary>Daily translations.</summary>
[JsonProperty("translations_daily")]
public MetricStats TranslationsDaily { get; set; }

/// <summary>Daily image moderations.</summary>
[JsonProperty("image_moderations_daily")]
public MetricStats ImageModerationsDaily { get; set; }

// Peak metrics (total = MAX of daily values)

/// <summary>Peak concurrent users.</summary>
[JsonProperty("concurrent_users")]
public MetricStats ConcurrentUsers { get; set; }

/// <summary>Peak concurrent connections.</summary>
[JsonProperty("concurrent_connections")]
public MetricStats ConcurrentConnections { get; set; }

// Rolling/cumulative metrics (total = LATEST daily value)

/// <summary>Total users.</summary>
[JsonProperty("users_total")]
public MetricStats UsersTotal { get; set; }

/// <summary>Users active in last 24 hours.</summary>
[JsonProperty("users_last_24_hours")]
public MetricStats UsersLast24Hours { get; set; }

/// <summary>MAU - users active in last 30 days.</summary>
[JsonProperty("users_last_30_days")]
public MetricStats UsersLast30Days { get; set; }

/// <summary>Users active this month.</summary>
[JsonProperty("users_month_to_date")]
public MetricStats UsersMonthToDate { get; set; }

/// <summary>Engaged MAU.</summary>
[JsonProperty("users_engaged_last_30_days")]
public MetricStats UsersEngagedLast30Days { get; set; }

/// <summary>Engaged users this month.</summary>
[JsonProperty("users_engaged_month_to_date")]
public MetricStats UsersEngagedMonthToDate { get; set; }

/// <summary>Total messages.</summary>
[JsonProperty("messages_total")]
public MetricStats MessagesTotal { get; set; }

/// <summary>Messages in last 24 hours.</summary>
[JsonProperty("messages_last_24_hours")]
public MetricStats MessagesLast24Hours { get; set; }

/// <summary>Messages in last 30 days.</summary>
[JsonProperty("messages_last_30_days")]
public MetricStats MessagesLast30Days { get; set; }

/// <summary>Messages this month.</summary>
[JsonProperty("messages_month_to_date")]
public MetricStats MessagesMonthToDate { get; set; }
}

/// <summary>
/// Options for querying team usage stats.
/// </summary>
public class QueryTeamUsageStatsOptions
{
/// <summary>
/// Month in YYYY-MM format (e.g., '2026-01'). Mutually exclusive with start_date/end_date.
/// Returns aggregated monthly values.
/// </summary>
[JsonProperty("month")]
public string Month { get; set; }

/// <summary>
/// Start date in YYYY-MM-DD format. Used with end_date for custom date range. Returns daily breakdown.
/// </summary>
[JsonProperty("start_date")]
public string StartDate { get; set; }

/// <summary>
/// End date in YYYY-MM-DD format. Used with start_date for custom date range. Returns daily breakdown.
/// </summary>
[JsonProperty("end_date")]
public string EndDate { get; set; }

/// <summary>Maximum number of teams to return per page (default: 30, max: 30).</summary>
[JsonProperty("limit")]
public int? Limit { get; set; }

/// <summary>Cursor for pagination to fetch next page of teams.</summary>
[JsonProperty("next")]
public string Next { get; set; }
}

/// <summary>
/// Response from querying team usage stats.
/// </summary>
public class QueryTeamUsageStatsResponse : ApiResponse
{
/// <summary>Array of team usage statistics.</summary>
[JsonProperty("teams")]
public List<TeamUsageStats> Teams { get; set; }

/// <summary>Cursor for pagination to fetch next page.</summary>
[JsonProperty("next")]
public string Next { get; set; }
}
}
125 changes: 125 additions & 0 deletions tests/StatsClientTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
using System;
using System.Threading.Tasks;
using FluentAssertions;
using NUnit.Framework;
using StreamChat.Models;

namespace StreamChatTests
{
/// <summary>Tests for <see cref="StatsClient"/></summary>
/// <remarks>
/// The tests follow arrange-act-assert pattern divided by empty lines.
/// Please make sure to follow the pattern to keep the consistency.
/// </remarks>
[TestFixture]
public class StatsClientTests : TestBase
{
[Test]
public async Task TestQueryTeamUsageStatsDefault()
{
var response = await _statsClient.QueryTeamUsageStatsAsync();

response.Teams.Should().NotBeNull();
}

[Test]
public async Task TestQueryTeamUsageStatsWithMonth()
{
var currentMonth = DateTime.UtcNow.ToString("yyyy-MM");

var response = await _statsClient.QueryTeamUsageStatsAsync(new QueryTeamUsageStatsOptions
{
Month = currentMonth,
});

response.Teams.Should().NotBeNull();
}

[Test]
public async Task TestQueryTeamUsageStatsWithDateRange()
{
var endDate = DateTime.UtcNow;
var startDate = endDate.AddDays(-7);

var response = await _statsClient.QueryTeamUsageStatsAsync(new QueryTeamUsageStatsOptions
{
StartDate = startDate.ToString("yyyy-MM-dd"),
EndDate = endDate.ToString("yyyy-MM-dd"),
});

response.Teams.Should().NotBeNull();
}

[Test]
public async Task TestQueryTeamUsageStatsWithPagination()
{
var response = await _statsClient.QueryTeamUsageStatsAsync(new QueryTeamUsageStatsOptions
{
Limit = 10,
});

response.Teams.Should().NotBeNull();

// If there's a next cursor, fetch the next page
if (!string.IsNullOrEmpty(response.Next))
{
var nextResponse = await _statsClient.QueryTeamUsageStatsAsync(new QueryTeamUsageStatsOptions
{
Limit = 10,
Next = response.Next,
});

nextResponse.Teams.Should().NotBeNull();
}
}

[Test]
public async Task TestQueryTeamUsageStatsResponseStructure()
{
// Query last year to maximize chance of getting data
var endDate = DateTime.UtcNow;
var startDate = endDate.AddDays(-365);

var response = await _statsClient.QueryTeamUsageStatsAsync(new QueryTeamUsageStatsOptions
{
StartDate = startDate.ToString("yyyy-MM-dd"),
EndDate = endDate.ToString("yyyy-MM-dd"),
});

response.Teams.Should().NotBeNull();

if (response.Teams.Count > 0)
{
var team = response.Teams[0];

// Verify team identifier
team.Team.Should().NotBeNull();

// Verify daily activity metrics
team.UsersDaily.Should().NotBeNull();
team.MessagesDaily.Should().NotBeNull();
team.TranslationsDaily.Should().NotBeNull();
team.ImageModerationsDaily.Should().NotBeNull();

// Verify peak metrics
team.ConcurrentUsers.Should().NotBeNull();
team.ConcurrentConnections.Should().NotBeNull();

// Verify rolling/cumulative metrics
team.UsersTotal.Should().NotBeNull();
team.UsersLast24Hours.Should().NotBeNull();
team.UsersLast30Days.Should().NotBeNull();
team.UsersMonthToDate.Should().NotBeNull();
team.UsersEngagedLast30Days.Should().NotBeNull();
team.UsersEngagedMonthToDate.Should().NotBeNull();
team.MessagesTotal.Should().NotBeNull();
team.MessagesLast24Hours.Should().NotBeNull();
team.MessagesLast30Days.Should().NotBeNull();
team.MessagesMonthToDate.Should().NotBeNull();

// Verify metric structure
team.UsersDaily.Total.Should().BeGreaterOrEqualTo(0);
}
}
}
}
1 change: 1 addition & 0 deletions tests/TestBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public abstract class TestBase
protected static readonly ITaskClient _taskClient = TestClientFactory.GetTaskClient();
protected static readonly IModerationClient _moderationClient = TestClientFactory.GetModerationClient();
protected static readonly IThreadClient _threadClient = TestClientFactory.GetThreadClient();
protected static readonly IStatsClient _statsClient = TestClientFactory.GetStatsClient();

private readonly List<ChannelWithConfig> _testChannels = new List<ChannelWithConfig>();
private readonly List<string> _testChannelTypes = new List<string>();
Expand Down
1 change: 1 addition & 0 deletions tests/TestClientFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,6 @@ public static class TestClientFactory
public static ITaskClient GetTaskClient() => _clientFactory.GetTaskClient();
public static IModerationClient GetModerationClient() => _clientFactory.GetModerationClient();
public static IThreadClient GetThreadClient() => _clientFactory.GetThreadClient();
public static IStatsClient GetStatsClient() => _clientFactory.GetStatsClient();
}
}
Loading