Skip to content

Commit 6f23091

Browse files
qdrant-cloud-botcursoragentAnush008
authored
feat: advertise qdrant-dotnet in the User-Agent (#124)
* 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/<version>". 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/<version>" 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 <cursoragent@cursor.com> * refactor --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Anush008 <mail@anush.sh>
1 parent 96aeba0 commit 6f23091

4 files changed

Lines changed: 162 additions & 2 deletions

File tree

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,21 @@ var client = new QdrantClient(grpcClient);
7272
> var client = new QdrantClient(grpcClient);
7373
> ```
7474
75+
#### User-Agent
76+
77+
When a channel is created via `QdrantChannel.ForAddress` (which both
78+
`QdrantClient("localhost")` and the `ClientConfiguration` overloads use), the
79+
client adds a `qdrant-dotnet/<version>` token to the request `User-Agent`
80+
alongside the gRPC library token, e.g.:
81+
82+
```
83+
grpc-dotnet/2.71.0 (.NET 8.0.4; CLR 8.0.4; net8.0; linux; x64) qdrant-dotnet/1.18.0
84+
```
85+
86+
This lets server-side tooling attribute traffic to the .NET client and its
87+
version. On .NET Framework, where the underlying channel handler must be
88+
configured manually, the token is not added automatically.
89+
7590
### Working with collections
7691
7792
Once a client has been created, create a new collection

src/Qdrant.Client/Grpc/QdrantChannel.cs

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
#if NETFRAMEWORK
21
using System.Net.Http;
3-
#endif
42
using Grpc.Core;
53
using Grpc.Core.Interceptors;
64
using Grpc.Net.Client;
@@ -84,6 +82,11 @@ public static QdrantChannel ForAddress(string address, ClientConfiguration confi
8482
public static QdrantChannel ForAddress(System.Uri address, ClientConfiguration configuration)
8583
{
8684
var channelOptions = new GrpcChannelOptions();
85+
86+
#if NETFRAMEWORK
87+
// .NET Framework has finicky HTTP/2 support, so preserve the original
88+
// behavior: only set an HttpClientHandler when certificate validation is
89+
// required and otherwise let Grpc.Net.Client pick its default handler.
8790
if (configuration.CertificateThumbprint is not null)
8891
{
8992
channelOptions.HttpHandler = new HttpClientHandler
@@ -92,6 +95,31 @@ public static QdrantChannel ForAddress(System.Uri address, ClientConfiguration c
9295
CertificateValidation.Thumbprint(configuration.CertificateThumbprint)
9396
};
9497
}
98+
#else
99+
HttpMessageHandler primaryHandler;
100+
if (configuration.CertificateThumbprint is not null)
101+
{
102+
// Thumbprint validation requires HttpClientHandler's callback shape.
103+
primaryHandler = new HttpClientHandler
104+
{
105+
ServerCertificateCustomValidationCallback =
106+
CertificateValidation.Thumbprint(configuration.CertificateThumbprint)
107+
};
108+
}
109+
else
110+
{
111+
#if NET6_0_OR_GREATER
112+
// Match Grpc.Net.Client's default handler; HttpClientHandler would drop
113+
// EnableMultipleHttp2Connections and cap throughput under concurrency.
114+
primaryHandler = new SocketsHttpHandler { EnableMultipleHttp2Connections = true };
115+
#else
116+
primaryHandler = new HttpClientHandler();
117+
#endif
118+
}
119+
120+
channelOptions.HttpHandler = new UserAgentHandler { InnerHandler = primaryHandler };
121+
#endif
122+
95123
var channel = GrpcChannel.ForAddress(address, channelOptions);
96124
return new QdrantChannel(channel, configuration);
97125
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
using System.Net.Http;
2+
using System.Net.Http.Headers;
3+
using System.Reflection;
4+
5+
namespace Qdrant.Client.Grpc;
6+
7+
/// <summary>
8+
/// Appends a <c>User-Agent</c> token to the request so
9+
/// server-side tooling can attribute traffic to this client.
10+
/// </summary>
11+
internal sealed class UserAgentHandler : DelegatingHandler
12+
{
13+
internal const string ProductName = "qdrant-dotnet";
14+
internal static readonly string ProductVersion = ResolveVersion();
15+
16+
private static readonly ProductInfoHeaderValue Token = new(ProductName, ProductVersion);
17+
18+
protected override Task<HttpResponseMessage> SendAsync(
19+
HttpRequestMessage request,
20+
CancellationToken cancellationToken)
21+
{
22+
// Append, not replace, to keep grpc-dotnet's token.
23+
// The guard avoids duplicates when a request is re-sent on retry.
24+
if (!request.Headers.UserAgent.Contains(Token))
25+
request.Headers.UserAgent.Add(Token);
26+
27+
return base.SendAsync(request, cancellationToken);
28+
}
29+
30+
private static string ResolveVersion()
31+
{
32+
var version = typeof(UserAgentHandler).Assembly
33+
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "0.0.0";
34+
35+
// Strip build metadata: "1.2.3+abc123" -> "1.2.3".
36+
var plus = version.IndexOf('+');
37+
return plus < 0 ? version : version.Substring(0, plus);
38+
}
39+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
using System.Net;
2+
using System.Net.Http;
3+
using System.Net.Http.Headers;
4+
using FluentAssertions;
5+
using Qdrant.Client.Grpc;
6+
using Xunit;
7+
8+
namespace Qdrant.Client;
9+
10+
public class UserAgentHandlerTests
11+
{
12+
[Fact]
13+
public async Task AddsQdrantUserAgentToken()
14+
{
15+
var (handler, capture) = CreateInvoker();
16+
17+
using var request = new HttpRequestMessage(HttpMethod.Post, "https://localhost");
18+
await handler.SendAsync(request, CancellationToken.None);
19+
20+
capture.Request.Should().NotBeNull();
21+
capture.Request!.Headers.UserAgent
22+
.Should().ContainSingle(p =>
23+
p.Product != null
24+
&& p.Product.Name == UserAgentHandler.ProductName
25+
&& p.Product.Version == UserAgentHandler.ProductVersion);
26+
}
27+
28+
[Fact]
29+
public async Task PreservesExistingUserAgentTokens()
30+
{
31+
var (handler, capture) = CreateInvoker();
32+
33+
using var request = new HttpRequestMessage(HttpMethod.Post, "https://localhost");
34+
// Mimic the token that Grpc.Net.Client injects.
35+
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("grpc-dotnet", "2.71.0"));
36+
37+
await handler.SendAsync(request, CancellationToken.None);
38+
39+
var userAgents = capture.Request!.Headers.UserAgent;
40+
userAgents.Should().Contain(p => p.Product != null && p.Product.Name == "grpc-dotnet");
41+
userAgents.Should().Contain(p => p.Product != null && p.Product.Name == UserAgentHandler.ProductName);
42+
}
43+
44+
[Fact]
45+
public async Task DoesNotDuplicateTokenOnRetry()
46+
{
47+
var (handler, capture) = CreateInvoker();
48+
49+
using var request = new HttpRequestMessage(HttpMethod.Post, "https://localhost");
50+
await handler.SendAsync(request, CancellationToken.None);
51+
// Re-send the same request instance (as can happen on a retry).
52+
await handler.SendAsync(request, CancellationToken.None);
53+
54+
capture.Request!.Headers.UserAgent
55+
.Count(p => p.Product != null && p.Product.Name == UserAgentHandler.ProductName)
56+
.Should().Be(1);
57+
}
58+
59+
private static (HttpMessageInvoker invoker, RequestCapturingHandler capture) CreateInvoker()
60+
{
61+
var capture = new RequestCapturingHandler();
62+
var handler = new UserAgentHandler { InnerHandler = capture };
63+
return (new HttpMessageInvoker(handler), capture);
64+
}
65+
66+
private sealed class RequestCapturingHandler : HttpMessageHandler
67+
{
68+
public HttpRequestMessage? Request { get; private set; }
69+
70+
protected override Task<HttpResponseMessage> SendAsync(
71+
HttpRequestMessage request,
72+
CancellationToken cancellationToken)
73+
{
74+
Request = request;
75+
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
76+
}
77+
}
78+
}

0 commit comments

Comments
 (0)