From 0f483fa9680107403a8a14176a9b0a60eb5ff293 Mon Sep 17 00:00:00 2001 From: Theo van Kraay Date: Fri, 12 Jun 2026 17:32:07 +0100 Subject: [PATCH] Set ApplicationName on CosmosClientOptions for UserAgent telemetry (#6481) Added CosmosOptionsHelper (in Microsoft.Agents.AI.CosmosNoSql namespace) that sets CosmosClientOptions.ApplicationName per component, producing wire-visible UserAgent suffixes: - CosmosChatHistoryProvider: Microsoft.Agents.CosmosNoSql.ChatHistory/{version} - CosmosCheckpointStore: Microsoft.Agents.CosmosNoSql.Checkpoint/{version} This ensures Cosmos DB requests from the Agent Framework are identifiable in telemetry, enabling usage tracking and diagnostics queries that can distinguish between chat history and checkpoint workloads. Addressed review feedback: - Truncates ApplicationName to 64 chars (Cosmos SDK max length) - Moved helper to Microsoft.Agents.AI.CosmosNoSql namespace (scoped ownership) - Uses StringComparison.Ordinal for IndexOf call When users provide their own CosmosClient instance, the ApplicationName is not overridden - users retain full control. Co-authored-by: TheovanKraay --- .../CosmosChatHistoryProvider.cs | 6 +- .../CosmosCheckpointStore.cs | 12 +-- .../CosmosOptionsHelper.cs | 80 +++++++++++++++++++ .../CosmosOptionsHelperTests.cs | 75 +++++++++++++++++ 4 files changed, 165 insertions(+), 8 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosOptionsHelper.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosOptionsHelperTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosChatHistoryProvider.cs index a8096b89c3..82f6ab1c28 100644 --- a/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosChatHistoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosChatHistoryProvider.cs @@ -8,6 +8,7 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Azure.Core; +using Microsoft.Agents.AI.CosmosNoSql; using Microsoft.Azure.Cosmos; using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; @@ -126,6 +127,7 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable Throw.IfNull(stateInitializer), stateKey ?? this.GetType().Name); this._cosmosClient = Throw.IfNull(cosmosClient); + CosmosOptionsHelper.EnsureApplicationName(this._cosmosClient, nameof(CosmosChatHistoryProvider)); this.DatabaseId = Throw.IfNullOrWhitespace(databaseId); this.ContainerId = Throw.IfNullOrWhitespace(containerId); this._container = this._cosmosClient.GetContainer(databaseId, containerId); @@ -157,7 +159,7 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable Func, IEnumerable>? provideOutputMessageFilter = null, Func, IEnumerable>? storeInputRequestMessageFilter = null, Func, IEnumerable>? storeInputResponseMessageFilter = null) - : this(new CosmosClient(Throw.IfNullOrWhitespace(connectionString)), databaseId, containerId, stateInitializer, ownsClient: true, stateKey, provideOutputMessageFilter, storeInputRequestMessageFilter, storeInputResponseMessageFilter) + : this(new CosmosClient(Throw.IfNullOrWhitespace(connectionString), CosmosOptionsHelper.CreateOptions(nameof(CosmosChatHistoryProvider))), databaseId, containerId, stateInitializer, ownsClient: true, stateKey, provideOutputMessageFilter, storeInputRequestMessageFilter, storeInputResponseMessageFilter) { } @@ -185,7 +187,7 @@ public sealed class CosmosChatHistoryProvider : ChatHistoryProvider, IDisposable Func, IEnumerable>? provideOutputMessageFilter = null, Func, IEnumerable>? storeInputRequestMessageFilter = null, Func, IEnumerable>? storeInputResponseMessageFilter = null) - : this(new CosmosClient(Throw.IfNullOrWhitespace(accountEndpoint), Throw.IfNull(tokenCredential)), databaseId, containerId, stateInitializer, ownsClient: true, stateKey, provideOutputMessageFilter, storeInputRequestMessageFilter, storeInputResponseMessageFilter) + : this(new CosmosClient(Throw.IfNullOrWhitespace(accountEndpoint), Throw.IfNull(tokenCredential), CosmosOptionsHelper.CreateOptions(nameof(CosmosChatHistoryProvider))), databaseId, containerId, stateInitializer, ownsClient: true, stateKey, provideOutputMessageFilter, storeInputRequestMessageFilter, storeInputResponseMessageFilter) { } diff --git a/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosCheckpointStore.cs b/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosCheckpointStore.cs index 461027dfa5..c85009718c 100644 --- a/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosCheckpointStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosCheckpointStore.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Text.Json; using System.Threading.Tasks; using Azure.Core; +using Microsoft.Agents.AI.CosmosNoSql; using Microsoft.Azure.Cosmos; using Microsoft.Shared.Diagnostics; using Newtonsoft.Json; @@ -37,7 +38,7 @@ public class CosmosCheckpointStore : JsonCheckpointStore, IDisposable /// Thrown when any string parameter is null or whitespace. public CosmosCheckpointStore(string connectionString, string databaseId, string containerId) { - var cosmosClientOptions = new CosmosClientOptions(); + var cosmosClientOptions = CosmosOptionsHelper.CreateOptions(nameof(CosmosCheckpointStore)); this._cosmosClient = new CosmosClient(Throw.IfNullOrWhitespace(connectionString), cosmosClientOptions); this._container = this._cosmosClient.GetContainer(Throw.IfNullOrWhitespace(databaseId), Throw.IfNullOrWhitespace(containerId)); @@ -55,12 +56,10 @@ public class CosmosCheckpointStore : JsonCheckpointStore, IDisposable /// Thrown when any string parameter is null or whitespace. public CosmosCheckpointStore(string accountEndpoint, TokenCredential tokenCredential, string databaseId, string containerId) { - var cosmosClientOptions = new CosmosClientOptions + var cosmosClientOptions = CosmosOptionsHelper.CreateOptions(nameof(CosmosCheckpointStore)); + cosmosClientOptions.SerializerOptions = new CosmosSerializationOptions { - SerializerOptions = new CosmosSerializationOptions - { - PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase - } + PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase }; this._cosmosClient = new CosmosClient(Throw.IfNullOrWhitespace(accountEndpoint), Throw.IfNull(tokenCredential), cosmosClientOptions); @@ -79,6 +78,7 @@ public class CosmosCheckpointStore : JsonCheckpointStore, IDisposable public CosmosCheckpointStore(CosmosClient cosmosClient, string databaseId, string containerId) { this._cosmosClient = Throw.IfNull(cosmosClient); + CosmosOptionsHelper.EnsureApplicationName(this._cosmosClient, nameof(CosmosCheckpointStore)); this._container = this._cosmosClient.GetContainer(Throw.IfNullOrWhitespace(databaseId), Throw.IfNullOrWhitespace(containerId)); this._ownsClient = false; diff --git a/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosOptionsHelper.cs b/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosOptionsHelper.cs new file mode 100644 index 0000000000..44ffcd3754 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosOptionsHelper.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Reflection; +using Microsoft.Azure.Cosmos; + +namespace Microsoft.Agents.AI.CosmosNoSql; + +/// +/// Provides shared Cosmos DB client configuration for Agent Framework Cosmos NoSQL integrations. +/// Ensures all internally-created instances carry a consistent +/// for telemetry and diagnostics. +/// +internal static class CosmosOptionsHelper +{ + /// + /// Maximum length allowed by the Cosmos DB .NET SDK for . + /// + private const int MaxApplicationNameLength = 64; + + private static readonly string s_version = GetVersion(); + + /// + /// Creates a instance pre-configured with the + /// Agent Framework application name for User-Agent identification. + /// + /// The fully-qualified component class name (e.g. "CosmosChatHistoryProvider"). + /// A new with set. + public static CosmosClientOptions CreateOptions(string component) + { + return new CosmosClientOptions + { + ApplicationName = BuildApplicationName(component) + }; + } + + /// + /// Ensures the given has an set. + /// If the client already has a non-empty ApplicationName, it is not overridden. + /// + /// The client to apply the application name to. + /// The fully-qualified component class name (e.g. "CosmosChatHistoryProvider"). + public static void EnsureApplicationName(CosmosClient cosmosClient, string component) + { + if (string.IsNullOrWhiteSpace(cosmosClient.ClientOptions.ApplicationName)) + { + cosmosClient.ClientOptions.ApplicationName = BuildApplicationName(component); + } + } + + private static string BuildApplicationName(string component) + { + var applicationName = $"Microsoft.Agents.AI.CosmosNoSql.{component}/{s_version}"; + + if (applicationName.Length > MaxApplicationNameLength) + { + applicationName = applicationName.Substring(0, MaxApplicationNameLength); + } + + return applicationName; + } + + private static string GetVersion() + { + if (typeof(CosmosOptionsHelper).Assembly.GetCustomAttribute()?.InformationalVersion is string version) + { + int pos = version.IndexOf('+', System.StringComparison.Ordinal); + if (pos >= 0) + { + version = version.Substring(0, pos); + } + + if (version.Length > 0) + { + return version; + } + } + + return "unknown"; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosOptionsHelperTests.cs b/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosOptionsHelperTests.cs new file mode 100644 index 0000000000..87a4b0483c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/CosmosOptionsHelperTests.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.CosmosNoSql; +using Microsoft.Azure.Cosmos; + +namespace Microsoft.Agents.AI.CosmosNoSql.UnitTests; + +public sealed class CosmosOptionsHelperTests +{ + [Fact] + public void CreateOptions_SetsApplicationName_WithComponentAndVersion() + { + // Act + var options = CosmosOptionsHelper.CreateOptions("CosmosChatHistoryProvider"); + + // Assert + Assert.NotNull(options.ApplicationName); + Assert.StartsWith("Microsoft.Agents.AI.CosmosNoSql.CosmosChatHistoryProvider/", options.ApplicationName); + } + + [Fact] + public void CreateOptions_DifferentComponents_ProduceDifferentNames() + { + // Act + var chatOptions = CosmosOptionsHelper.CreateOptions("CosmosChatHistoryProvider"); + var checkpointOptions = CosmosOptionsHelper.CreateOptions("CosmosCheckpointStore"); + + // Assert + Assert.NotEqual(chatOptions.ApplicationName, checkpointOptions.ApplicationName); + Assert.Contains("CosmosChatHistoryProvider", chatOptions.ApplicationName); + Assert.Contains("CosmosCheckpointStore", checkpointOptions.ApplicationName); + } + + [Fact] + public void CreateOptions_ApplicationName_DoesNotExceedMaxLength() + { + // Use a deliberately long component name to trigger truncation + var longComponent = new string('X', 100); + + // Act + var options = CosmosOptionsHelper.CreateOptions(longComponent); + + // Assert + Assert.True(options.ApplicationName!.Length <= 64, + $"ApplicationName length {options.ApplicationName.Length} exceeds max 64"); + } + + [Fact] + public void EnsureApplicationName_SetsName_WhenClientHasNone() + { + // Arrange + var clientOptions = new CosmosClientOptions(); + Assert.Null(clientOptions.ApplicationName); + + // Act + var options = CosmosOptionsHelper.CreateOptions("CosmosChatHistoryProvider"); + + // Assert - verify the returned options have ApplicationName set + Assert.NotNull(options.ApplicationName); + Assert.NotEmpty(options.ApplicationName); + } + + [Fact] + public void CreateOptions_ApplicationName_ContainsVersion() + { + // Act + var options = CosmosOptionsHelper.CreateOptions("CosmosChatHistoryProvider"); + + // Assert - should contain a "/" followed by version info + Assert.Contains("/", options.ApplicationName); + var parts = options.ApplicationName!.Split('/'); + Assert.Equal(2, parts.Length); + Assert.False(string.IsNullOrWhiteSpace(parts[1]), "Version portion should not be empty"); + } +}