From c3b438a20efc88e8d384b96a4103341e11b2e23f Mon Sep 17 00:00:00 2001 From: root <111755117+qdrant-cloud-bot@users.noreply.github.com> Date: Wed, 17 Jun 2026 06:11:07 +0000 Subject: [PATCH 1/2] feat: advertise qdrant-dotnet in the User-Agent The SDK previously set no Qdrant-branded User-Agent, so the only token on the wire was the gRPC library's own "grpc-dotnet/". Server-side tooling (e.g. the Qdrant Cloud auth sidecars' client metrics) therefore could not attribute traffic to the .NET client. Grpc.Net.Client does not allow replacing the User-Agent through gRPC metadata, so a DelegatingHandler now adds a "qdrant-dotnet/" token at the HTTP layer. The version is resolved from the assembly informational version (stamped by MinVer). The handler is wired into QdrantChannel.ForAddress for net6.0 and netstandard2.0; on .NET Framework, where the channel handler must be configured manually, the original behavior is preserved. Co-authored-by: Cursor --- README.md | 15 ++++ src/Qdrant.Client/Grpc/QdrantChannel.cs | 22 +++++- src/Qdrant.Client/Grpc/UserAgentHandler.cs | 67 ++++++++++++++++ .../UserAgentHandlerTests.cs | 78 +++++++++++++++++++ 4 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 src/Qdrant.Client/Grpc/UserAgentHandler.cs create mode 100644 tests/Qdrant.Client.Tests/UserAgentHandlerTests.cs diff --git a/README.md b/README.md index d5b96eb..4fe5a01 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,21 @@ var client = new QdrantClient(grpcClient); > var client = new QdrantClient(grpcClient); > ``` +#### User-Agent + +When a channel is created via `QdrantChannel.ForAddress` (which both +`QdrantClient("localhost")` and the `ClientConfiguration` overloads use), the +client adds a `qdrant-dotnet/` token to the request `User-Agent` +alongside the gRPC library token, e.g.: + +``` +grpc-dotnet/2.71.0 (.NET 8.0.4; CLR 8.0.4; net8.0; linux; x64) qdrant-dotnet/1.18.0 +``` + +This lets server-side tooling attribute traffic to the .NET client and its +version. On .NET Framework, where the underlying channel handler must be +configured manually, the token is not added automatically. + ### Working with collections Once a client has been created, create a new collection diff --git a/src/Qdrant.Client/Grpc/QdrantChannel.cs b/src/Qdrant.Client/Grpc/QdrantChannel.cs index b089840..5065c42 100644 --- a/src/Qdrant.Client/Grpc/QdrantChannel.cs +++ b/src/Qdrant.Client/Grpc/QdrantChannel.cs @@ -1,6 +1,4 @@ -#if NETFRAMEWORK using System.Net.Http; -#endif using Grpc.Core; using Grpc.Core.Interceptors; using Grpc.Net.Client; @@ -84,6 +82,11 @@ public static QdrantChannel ForAddress(string address, ClientConfiguration confi public static QdrantChannel ForAddress(System.Uri address, ClientConfiguration configuration) { var channelOptions = new GrpcChannelOptions(); + +#if NETFRAMEWORK + // .NET Framework has finicky HTTP/2 support, so preserve the original + // behavior: only set an HttpClientHandler when certificate validation is + // required and otherwise let Grpc.Net.Client pick its default handler. if (configuration.CertificateThumbprint is not null) { channelOptions.HttpHandler = new HttpClientHandler @@ -92,6 +95,21 @@ public static QdrantChannel ForAddress(System.Uri address, ClientConfiguration c CertificateValidation.Thumbprint(configuration.CertificateThumbprint) }; } +#else + var primaryHandler = new HttpClientHandler(); + if (configuration.CertificateThumbprint is not null) + { + primaryHandler.ServerCertificateCustomValidationCallback = + CertificateValidation.Thumbprint(configuration.CertificateThumbprint); + } + + // Advertise a Qdrant-branded "qdrant-dotnet/" token in the + // User-Agent. Grpc.Net.Client does not let us set the User-Agent through + // gRPC metadata, so we add it at the HTTP layer via a delegating handler + // that wraps the primary handler. + channelOptions.HttpHandler = new UserAgentHandler { InnerHandler = primaryHandler }; +#endif + var channel = GrpcChannel.ForAddress(address, channelOptions); return new QdrantChannel(channel, configuration); } diff --git a/src/Qdrant.Client/Grpc/UserAgentHandler.cs b/src/Qdrant.Client/Grpc/UserAgentHandler.cs new file mode 100644 index 0000000..0889ff1 --- /dev/null +++ b/src/Qdrant.Client/Grpc/UserAgentHandler.cs @@ -0,0 +1,67 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Reflection; + +namespace Qdrant.Client.Grpc; + +/// +/// A that advertises the Qdrant .NET client in the +/// request User-Agent header. +/// +/// +/// The underlying gRPC library (Grpc.Net.Client) always injects its own +/// grpc-dotnet/<version> token and does not let callers replace the +/// User-Agent via gRPC metadata. By adding a qdrant-dotnet/<version> +/// token at the HTTP layer, requests carry a Qdrant-branded token (e.g. +/// grpc-dotnet/2.71.0 (...) qdrant-dotnet/1.2.3) so that server-side tooling +/// can attribute traffic to this client and its version. +/// +internal sealed class UserAgentHandler : DelegatingHandler +{ + /// + /// The product name advertised in the User-Agent header. + /// + internal const string ProductName = "qdrant-dotnet"; + + /// + /// The product version advertised in the User-Agent header, resolved from + /// the assembly's informational version (stamped at build time). Falls back to the + /// assembly version, and finally to "0.0.0" when neither is available. + /// + internal static readonly string ProductVersion = ResolveVersion(); + + private static readonly ProductInfoHeaderValue QdrantUserAgent = + new(ProductName, ProductVersion); + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + // Add our token to the User-Agent. We add rather than clear so the gRPC + // library and runtime information is preserved; the resulting header looks + // like "grpc-dotnet/ (...) qdrant-dotnet/". Token order is not + // significant for attribution. + if (!request.Headers.UserAgent.Contains(QdrantUserAgent)) + request.Headers.UserAgent.Add(QdrantUserAgent); + + return base.SendAsync(request, cancellationToken); + } + + private static string ResolveVersion() + { + var assembly = typeof(UserAgentHandler).Assembly; + + var informational = assembly + .GetCustomAttribute()? + .InformationalVersion; + if (!string.IsNullOrEmpty(informational)) + { + // Strip any build metadata suffix (e.g. "1.2.3+abcdef0") to keep the + // token compact and avoid leaking commit hashes. + var plus = informational!.IndexOf('+'); + return plus >= 0 ? informational.Substring(0, plus) : informational; + } + + return assembly.GetName().Version?.ToString() ?? "0.0.0"; + } +} diff --git a/tests/Qdrant.Client.Tests/UserAgentHandlerTests.cs b/tests/Qdrant.Client.Tests/UserAgentHandlerTests.cs new file mode 100644 index 0000000..fb49733 --- /dev/null +++ b/tests/Qdrant.Client.Tests/UserAgentHandlerTests.cs @@ -0,0 +1,78 @@ +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using FluentAssertions; +using Qdrant.Client.Grpc; +using Xunit; + +namespace Qdrant.Client; + +public class UserAgentHandlerTests +{ + [Fact] + public async Task AddsQdrantUserAgentToken() + { + var (handler, capture) = CreateInvoker(); + + using var request = new HttpRequestMessage(HttpMethod.Post, "https://localhost"); + await handler.SendAsync(request, CancellationToken.None); + + capture.Request.Should().NotBeNull(); + capture.Request!.Headers.UserAgent + .Should().ContainSingle(p => + p.Product != null + && p.Product.Name == UserAgentHandler.ProductName + && p.Product.Version == UserAgentHandler.ProductVersion); + } + + [Fact] + public async Task PreservesExistingUserAgentTokens() + { + var (handler, capture) = CreateInvoker(); + + using var request = new HttpRequestMessage(HttpMethod.Post, "https://localhost"); + // Mimic the token that Grpc.Net.Client injects. + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("grpc-dotnet", "2.71.0")); + + await handler.SendAsync(request, CancellationToken.None); + + var userAgents = capture.Request!.Headers.UserAgent; + userAgents.Should().Contain(p => p.Product != null && p.Product.Name == "grpc-dotnet"); + userAgents.Should().Contain(p => p.Product != null && p.Product.Name == UserAgentHandler.ProductName); + } + + [Fact] + public async Task DoesNotDuplicateTokenOnRetry() + { + var (handler, capture) = CreateInvoker(); + + using var request = new HttpRequestMessage(HttpMethod.Post, "https://localhost"); + await handler.SendAsync(request, CancellationToken.None); + // Re-send the same request instance (as can happen on a retry). + await handler.SendAsync(request, CancellationToken.None); + + capture.Request!.Headers.UserAgent + .Count(p => p.Product != null && p.Product.Name == UserAgentHandler.ProductName) + .Should().Be(1); + } + + private static (HttpMessageInvoker invoker, RequestCapturingHandler capture) CreateInvoker() + { + var capture = new RequestCapturingHandler(); + var handler = new UserAgentHandler { InnerHandler = capture }; + return (new HttpMessageInvoker(handler), capture); + } + + private sealed class RequestCapturingHandler : HttpMessageHandler + { + public HttpRequestMessage? Request { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Request = request; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + } + } +} From 414052a0bed5328bb0cfa5ae3743b915980dc804 Mon Sep 17 00:00:00 2001 From: Anush008 Date: Wed, 17 Jun 2026 15:59:12 +0530 Subject: [PATCH 2/2] refactor --- src/Qdrant.Client/Grpc/QdrantChannel.cs | 24 +++++++--- src/Qdrant.Client/Grpc/UserAgentHandler.cs | 52 +++++----------------- 2 files changed, 29 insertions(+), 47 deletions(-) diff --git a/src/Qdrant.Client/Grpc/QdrantChannel.cs b/src/Qdrant.Client/Grpc/QdrantChannel.cs index 5065c42..99772a7 100644 --- a/src/Qdrant.Client/Grpc/QdrantChannel.cs +++ b/src/Qdrant.Client/Grpc/QdrantChannel.cs @@ -96,17 +96,27 @@ public static QdrantChannel ForAddress(System.Uri address, ClientConfiguration c }; } #else - var primaryHandler = new HttpClientHandler(); + HttpMessageHandler primaryHandler; if (configuration.CertificateThumbprint is not null) { - primaryHandler.ServerCertificateCustomValidationCallback = - CertificateValidation.Thumbprint(configuration.CertificateThumbprint); + // Thumbprint validation requires HttpClientHandler's callback shape. + primaryHandler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = + CertificateValidation.Thumbprint(configuration.CertificateThumbprint) + }; + } + else + { +#if NET6_0_OR_GREATER + // Match Grpc.Net.Client's default handler; HttpClientHandler would drop + // EnableMultipleHttp2Connections and cap throughput under concurrency. + primaryHandler = new SocketsHttpHandler { EnableMultipleHttp2Connections = true }; +#else + primaryHandler = new HttpClientHandler(); +#endif } - // Advertise a Qdrant-branded "qdrant-dotnet/" token in the - // User-Agent. Grpc.Net.Client does not let us set the User-Agent through - // gRPC metadata, so we add it at the HTTP layer via a delegating handler - // that wraps the primary handler. channelOptions.HttpHandler = new UserAgentHandler { InnerHandler = primaryHandler }; #endif diff --git a/src/Qdrant.Client/Grpc/UserAgentHandler.cs b/src/Qdrant.Client/Grpc/UserAgentHandler.cs index 0889ff1..b6163eb 100644 --- a/src/Qdrant.Client/Grpc/UserAgentHandler.cs +++ b/src/Qdrant.Client/Grpc/UserAgentHandler.cs @@ -5,63 +5,35 @@ namespace Qdrant.Client.Grpc; /// -/// A that advertises the Qdrant .NET client in the -/// request User-Agent header. +/// Appends a User-Agent token to the request so +/// server-side tooling can attribute traffic to this client. /// -/// -/// The underlying gRPC library (Grpc.Net.Client) always injects its own -/// grpc-dotnet/<version> token and does not let callers replace the -/// User-Agent via gRPC metadata. By adding a qdrant-dotnet/<version> -/// token at the HTTP layer, requests carry a Qdrant-branded token (e.g. -/// grpc-dotnet/2.71.0 (...) qdrant-dotnet/1.2.3) so that server-side tooling -/// can attribute traffic to this client and its version. -/// internal sealed class UserAgentHandler : DelegatingHandler { - /// - /// The product name advertised in the User-Agent header. - /// internal const string ProductName = "qdrant-dotnet"; - - /// - /// The product version advertised in the User-Agent header, resolved from - /// the assembly's informational version (stamped at build time). Falls back to the - /// assembly version, and finally to "0.0.0" when neither is available. - /// internal static readonly string ProductVersion = ResolveVersion(); - private static readonly ProductInfoHeaderValue QdrantUserAgent = - new(ProductName, ProductVersion); + private static readonly ProductInfoHeaderValue Token = new(ProductName, ProductVersion); protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { - // Add our token to the User-Agent. We add rather than clear so the gRPC - // library and runtime information is preserved; the resulting header looks - // like "grpc-dotnet/ (...) qdrant-dotnet/". Token order is not - // significant for attribution. - if (!request.Headers.UserAgent.Contains(QdrantUserAgent)) - request.Headers.UserAgent.Add(QdrantUserAgent); + // Append, not replace, to keep grpc-dotnet's token. + // The guard avoids duplicates when a request is re-sent on retry. + if (!request.Headers.UserAgent.Contains(Token)) + request.Headers.UserAgent.Add(Token); return base.SendAsync(request, cancellationToken); } private static string ResolveVersion() { - var assembly = typeof(UserAgentHandler).Assembly; - - var informational = assembly - .GetCustomAttribute()? - .InformationalVersion; - if (!string.IsNullOrEmpty(informational)) - { - // Strip any build metadata suffix (e.g. "1.2.3+abcdef0") to keep the - // token compact and avoid leaking commit hashes. - var plus = informational!.IndexOf('+'); - return plus >= 0 ? informational.Substring(0, plus) : informational; - } + var version = typeof(UserAgentHandler).Assembly + .GetCustomAttribute()?.InformationalVersion ?? "0.0.0"; - return assembly.GetName().Version?.ToString() ?? "0.0.0"; + // Strip build metadata: "1.2.3+abc123" -> "1.2.3". + var plus = version.IndexOf('+'); + return plus < 0 ? version : version.Substring(0, plus); } }