From a47b26c498b5b80338fd90717b00a2122760a087 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Tue, 26 May 2026 16:51:23 +0200 Subject: [PATCH 1/3] feat: Upwards-recursively read `.env.braintrust` containing `BRAINTRUST_API_KEY` --- README.md | 8 + src/Braintrust.Sdk/Api/BraintrustApiClient.cs | 14 +- src/Braintrust.Sdk/Api/Internal/BtqlClient.cs | 3 +- .../Config/BraintrustApiKeyDiscovery.cs | 171 +++++++++++++++ src/Braintrust.Sdk/Config/BraintrustConfig.cs | 59 +++++- src/Braintrust.Sdk/Trace/BraintrustTracing.cs | 18 +- .../Trace/LazyBraintrustOtlpTraceExporter.cs | 128 +++++++++++ .../Api/BraintrustApiClientTest.cs | 39 ++++ .../Config/BraintrustApiKeyDiscoveryTest.cs | 199 ++++++++++++++++++ .../Config/BraintrustConfigTest.cs | 6 +- .../Trace/BraintrustTracingTest.cs | 19 ++ 11 files changed, 644 insertions(+), 20 deletions(-) create mode 100644 src/Braintrust.Sdk/Config/BraintrustApiKeyDiscovery.cs create mode 100644 src/Braintrust.Sdk/Trace/LazyBraintrustOtlpTraceExporter.cs create mode 100644 tests/Braintrust.Sdk.Tests/Config/BraintrustApiKeyDiscoveryTest.cs diff --git a/README.md b/README.md index a07944f..938f504 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,14 @@ Install the dotnet 8 framework - Linux: Follow [these instructions](https://learn.microsoft.com/en-us/dotnet/core/install/linux-ubuntu-install?tabs=dotnet10&pivots=os-linux-ubuntu-2404) - Windows: Follow [these instructions](https://learn.microsoft.com/en-us/dotnet/core/install/windows) +Set `BRAINTRUST_API_KEY` in your environment, or place it in the nearest `.env.braintrust` file: + +```dotenv +BRAINTRUST_API_KEY=your-api-key +``` + +The SDK only reads `BRAINTRUST_API_KEY` from `.env.braintrust`; it does not load other dotenv variables. + ### List All Examples ```bash diff --git a/src/Braintrust.Sdk/Api/BraintrustApiClient.cs b/src/Braintrust.Sdk/Api/BraintrustApiClient.cs index 8931e07..831e516 100644 --- a/src/Braintrust.Sdk/Api/BraintrustApiClient.cs +++ b/src/Braintrust.Sdk/Api/BraintrustApiClient.cs @@ -154,15 +154,17 @@ public async Task GetOrCreateProjectAndOrgInfo() private async Task Login() { - var request = new LoginRequest(_config.ApiKey); - return await PostAsync("/api/apikey/login", request) + var apiKey = await _config.GetRequiredApiKeyAsync().ConfigureAwait(false); + var request = new LoginRequest(apiKey); + return await PostAsync("/api/apikey/login", request, apiKey: apiKey) .ConfigureAwait(false); } private async Task GetAsync(string path, CancellationToken cancellationToken = default) { + var apiKey = await _config.GetRequiredApiKeyAsync(cancellationToken).ConfigureAwait(false); using var request = new HttpRequestMessage(HttpMethod.Get, path); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _config.ApiKey); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); @@ -172,10 +174,12 @@ private async Task GetAsync(string path, CancellationToken private async Task PostAsync( string path, TRequest body, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + string? apiKey = null) { + apiKey ??= await _config.GetRequiredApiKeyAsync(cancellationToken).ConfigureAwait(false); using var request = new HttpRequestMessage(HttpMethod.Post, path); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _config.ApiKey); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); request.Content = JsonContent.Create(body, options: _jsonOptions); diff --git a/src/Braintrust.Sdk/Api/Internal/BtqlClient.cs b/src/Braintrust.Sdk/Api/Internal/BtqlClient.cs index c414d97..9920c6d 100644 --- a/src/Braintrust.Sdk/Api/Internal/BtqlClient.cs +++ b/src/Braintrust.Sdk/Api/Internal/BtqlClient.cs @@ -96,8 +96,9 @@ public async Task>> Query private async Task PostBtqlAsync(string query, CancellationToken cancellationToken) { + var apiKey = await _config.GetRequiredApiKeyAsync(cancellationToken).ConfigureAwait(false); using var request = new HttpRequestMessage(HttpMethod.Post, "/btql"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _config.ApiKey); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); request.Content = JsonContent.Create(new BtqlRequest(query), options: _jsonOptions); diff --git a/src/Braintrust.Sdk/Config/BraintrustApiKeyDiscovery.cs b/src/Braintrust.Sdk/Config/BraintrustApiKeyDiscovery.cs new file mode 100644 index 0000000..b4b7c75 --- /dev/null +++ b/src/Braintrust.Sdk/Config/BraintrustApiKeyDiscovery.cs @@ -0,0 +1,171 @@ +namespace Braintrust.Sdk.Config; + +internal static class BraintrustApiKeyDiscovery +{ + internal const int BraintrustEnvSearchParentLimit = 64; + private const string BraintrustEnvFileName = ".env.braintrust"; + private const string BraintrustApiKeyName = "BRAINTRUST_API_KEY"; + + internal static async Task FindInBraintrustEnvFileAsync(CancellationToken cancellationToken = default) + { + string currentDirectory; + try + { + currentDirectory = Directory.GetCurrentDirectory(); + } + catch + { + return null; + } + + var paths = new List(); + for (var dir = currentDirectory; paths.Count <= BraintrustEnvSearchParentLimit; dir = Path.GetDirectoryName(dir)!) + { + paths.Add(Path.Combine(dir, BraintrustEnvFileName)); + + var parent = Path.GetDirectoryName(dir); + if (string.IsNullOrEmpty(parent) || string.Equals(parent, dir, StringComparison.Ordinal)) + { + break; + } + } + + // Start the reads together, then await nearest-first so a parent file never beats a closer file. + var reads = paths + .Select((path, index) => File.ReadAllTextAsync(path, cancellationToken) + .ContinueWith( + task => new + { + Index = index, + Contents = task.IsCompletedSuccessfully ? task.Result : null, + Error = task.IsCompletedSuccessfully + ? null + : task.IsCanceled + ? new OperationCanceledException(cancellationToken) + : task.Exception?.GetBaseException() + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default)) + .ToArray(); + + for (var i = 0; i < reads.Length; i++) + { + var result = await reads[i].ConfigureAwait(false); + if (result.Error == null) + { + var apiKey = ParseBraintrustApiKey(result.Contents!); + return string.IsNullOrWhiteSpace(apiKey) ? null : apiKey; + } + + if (result.Error is OperationCanceledException cancellation) + { + throw cancellation; + } + + if (result.Error is FileNotFoundException or DirectoryNotFoundException) + { + continue; + } + + return null; + } + + return null; + } + + internal static string? ParseBraintrustApiKey(string contents) + { + string? parsedApiKey = null; + + using var reader = new StringReader(contents); + string? line; + while ((line = reader.ReadLine()) != null) + { + var current = line.TrimStart(); + if (current.Length == 0 || current[0] == '#') + { + continue; + } + + if (current.StartsWith("export", StringComparison.Ordinal)) + { + var afterExport = current["export".Length..]; + if (afterExport.Length > 0 && char.IsWhiteSpace(afterExport[0])) + { + current = afterExport.TrimStart(); + } + } + + var equalsIndex = current.IndexOf('='); + if (equalsIndex <= 0) + { + continue; + } + + var key = current[..equalsIndex].Trim(); + if (!string.Equals(key, BraintrustApiKeyName, StringComparison.Ordinal)) + { + continue; + } + + var rawValue = current[(equalsIndex + 1)..].TrimStart(); + if (rawValue.Length == 0) + { + parsedApiKey = string.Empty; + continue; + } + + if (rawValue[0] is '"' or '\'') + { + var quote = rawValue[0]; + var value = new System.Text.StringBuilder(); + var escaped = false; + var closed = false; + + for (var i = 1; i < rawValue.Length; i++) + { + var ch = rawValue[i]; + if (escaped) + { + value.Append(quote == '"' && ch == 'n' ? '\n' + : quote == '"' && ch == 'r' ? '\r' + : quote == '"' && ch == 't' ? '\t' + : ch); + escaped = false; + } + else if (ch == '\\') + { + escaped = true; + } + else if (ch == quote) + { + closed = true; + break; + } + else + { + value.Append(ch); + } + } + + parsedApiKey = closed ? value.ToString() : rawValue[1..]; + continue; + } + + var commentIndex = -1; + for (var i = 0; i < rawValue.Length; i++) + { + if (rawValue[i] == '#' && (i == 0 || char.IsWhiteSpace(rawValue[i - 1]))) + { + commentIndex = i; + break; + } + } + + parsedApiKey = (commentIndex >= 0 ? rawValue[..commentIndex] : rawValue).Trim(); + } + + return parsedApiKey; + } +} diff --git a/src/Braintrust.Sdk/Config/BraintrustConfig.cs b/src/Braintrust.Sdk/Config/BraintrustConfig.cs index 3afe59a..7387b9c 100644 --- a/src/Braintrust.Sdk/Config/BraintrustConfig.cs +++ b/src/Braintrust.Sdk/Config/BraintrustConfig.cs @@ -9,7 +9,12 @@ namespace Braintrust.Sdk.Config; /// public sealed class BraintrustConfig : BaseConfig { - public string ApiKey { get; } + private const string ApiKeySettingName = "BRAINTRUST_API_KEY"; + + private readonly bool _hasApiKeyOverride; + private readonly string? _apiKeyOverride; + + public string ApiKey => GetRequiredApiKeyAsync().GetAwaiter().GetResult(); public string ApiUrl { get; } public string AppUrl { get; } public string TracesPath { get; } @@ -39,7 +44,12 @@ public static BraintrustConfig Of(params (string Key, string? Value)[] envOverri private BraintrustConfig(IDictionary envOverrides) : base(envOverrides) { - ApiKey = GetRequiredConfig("BRAINTRUST_API_KEY"); + if (envOverrides.TryGetValue(ApiKeySettingName, out var apiKeyOverride)) + { + _hasApiKeyOverride = true; + _apiKeyOverride = apiKeyOverride == NullOverride ? null : apiKeyOverride; + } + ApiUrl = GetConfig("BRAINTRUST_API_URL", "https://api.braintrust.dev"); AppUrl = GetConfig("BRAINTRUST_APP_URL", "https://www.braintrust.dev"); TracesPath = GetConfig("BRAINTRUST_TRACES_PATH", "/otel/v1/traces"); @@ -57,6 +67,51 @@ private BraintrustConfig(IDictionary envOverrides) : base(envOv } } + internal string? TryGetImmediateApiKey() + { + if (_hasApiKeyOverride) + { + return string.IsNullOrWhiteSpace(_apiKeyOverride) ? null : _apiKeyOverride; + } + + var value = Environment.GetEnvironmentVariable(ApiKeySettingName); + if (value == NullOverride) + { + return null; + } + + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + internal async Task GetRequiredApiKeyAsync(CancellationToken cancellationToken = default) + { + var immediateApiKey = TryGetImmediateApiKey(); + if (immediateApiKey != null) + { + return immediateApiKey; + } + + if (_hasApiKeyOverride || Environment.GetEnvironmentVariable(ApiKeySettingName) == NullOverride) + { + throw MissingApiKeyException(); + } + + var apiKey = await BraintrustApiKeyDiscovery.FindInBraintrustEnvFileAsync(cancellationToken) + .ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(apiKey)) + { + return apiKey; + } + + throw MissingApiKeyException(); + } + + private static InvalidOperationException MissingApiKeyException() + { + return new InvalidOperationException( + "BRAINTRUST_API_KEY is required. Set BRAINTRUST_API_KEY, define it in .env.braintrust, or provide an API key."); + } + /// /// The parent attribute tells Braintrust where to send otel data. /// diff --git a/src/Braintrust.Sdk/Trace/BraintrustTracing.cs b/src/Braintrust.Sdk/Trace/BraintrustTracing.cs index 86ca4fa..949b7bd 100644 --- a/src/Braintrust.Sdk/Trace/BraintrustTracing.cs +++ b/src/Braintrust.Sdk/Trace/BraintrustTracing.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using Braintrust.Sdk.Config; using Microsoft.Extensions.Logging; +using OpenTelemetry; using OpenTelemetry.Exporter; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; @@ -78,13 +79,12 @@ public static void Enable(BraintrustConfig config, TracerProviderBuilder tracerP .AddService(serviceName: OtelServiceName, serviceVersion: InstrumentationVersion)) .AddSource(InstrumentationName) .AddProcessor(spanProcessor) - .AddOtlpExporter(otlpOptions => - { - otlpOptions.Protocol = OtlpExportProtocol.HttpProtobuf; - otlpOptions.Endpoint = new Uri($"{config.ApiUrl}{config.TracesPath}"); - otlpOptions.Headers = BuildHeaders(config); - otlpOptions.TimeoutMilliseconds = (int)config.RequestTimeout.TotalMilliseconds; - }) + .AddProcessor(new BatchActivityExportProcessor( + new LazyBraintrustOtlpTraceExporter(config), + maxQueueSize: 2048, + scheduledDelayMilliseconds: 5000, + exporterTimeoutMilliseconds: 30000, + maxExportBatchSize: 512)) .SetSampler(new AlwaysOnSampler()); } @@ -104,11 +104,11 @@ public static ActivitySource GetActivitySource() return _activitySource.Value; } - private static string BuildHeaders(BraintrustConfig config) + internal static string BuildHeaders(BraintrustConfig config, string apiKey) { var headers = new List { - $"Authorization=Bearer {config.ApiKey}" + $"Authorization=Bearer {apiKey}" }; // Add parent header if available diff --git a/src/Braintrust.Sdk/Trace/LazyBraintrustOtlpTraceExporter.cs b/src/Braintrust.Sdk/Trace/LazyBraintrustOtlpTraceExporter.cs new file mode 100644 index 0000000..d6d3560 --- /dev/null +++ b/src/Braintrust.Sdk/Trace/LazyBraintrustOtlpTraceExporter.cs @@ -0,0 +1,128 @@ +using System.Diagnostics; +using System.Reflection; +using Braintrust.Sdk.Config; +using OpenTelemetry; +using OpenTelemetry.Exporter; + +namespace Braintrust.Sdk.Trace; + +internal sealed class LazyBraintrustOtlpTraceExporter : BaseExporter +{ + private static readonly MethodInfo SetParentProviderMethod = + typeof(BaseExporter) + .GetProperty(nameof(ParentProvider))! + .GetSetMethod(nonPublic: true)!; + + private readonly BraintrustConfig _config; + private readonly object _lock = new(); + private Task? _exporterTask; + + internal LazyBraintrustOtlpTraceExporter(BraintrustConfig config) + { + _config = config; + } + + public override ExportResult Export(in Batch batch) + { + try + { + return GetExporterAsync().GetAwaiter().GetResult().Export(batch); + } + catch + { + return ExportResult.Failure; + } + } + + protected override bool OnForceFlush(int timeoutMilliseconds) + { + return TryGetExporter(GetExporterAsync(), timeoutMilliseconds, out var exporter) + && exporter.ForceFlush(timeoutMilliseconds); + } + + protected override bool OnShutdown(int timeoutMilliseconds) + { + Task? exporterTask; + lock (_lock) + { + exporterTask = _exporterTask; + } + + return exporterTask == null + || (TryGetExporter(exporterTask, timeoutMilliseconds, out var exporter) + && exporter.Shutdown(timeoutMilliseconds)); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + Task? exporterTask; + lock (_lock) + { + exporterTask = _exporterTask; + } + + if (exporterTask is { IsCompletedSuccessfully: true }) + { + exporterTask.Result.Dispose(); + } + } + + base.Dispose(disposing); + } + + private Task GetExporterAsync() + { + lock (_lock) + { + _exporterTask ??= CreateExporterAsync(); + return _exporterTask; + } + } + + private async Task CreateExporterAsync() + { + var apiKey = await _config.GetRequiredApiKeyAsync().ConfigureAwait(false); + var exporter = new OtlpTraceExporter(new OtlpExporterOptions + { + Protocol = OtlpExportProtocol.HttpProtobuf, + Endpoint = new Uri($"{_config.ApiUrl}{_config.TracesPath}"), + Headers = BraintrustTracing.BuildHeaders(_config, apiKey), + TimeoutMilliseconds = (int)_config.RequestTimeout.TotalMilliseconds + }); + // OpenTelemetry only assigns ParentProvider to the outer exporter, but OtlpTraceExporter + // reads resources from its own ParentProvider during export. + SetParentProviderMethod.Invoke(exporter, new object?[] { ParentProvider }); + return exporter; + } + + private static bool TryGetExporter( + Task exporterTask, + int timeoutMilliseconds, + out OtlpTraceExporter exporter) + { + try + { + if (timeoutMilliseconds == Timeout.Infinite) + { + exporter = exporterTask.GetAwaiter().GetResult(); + return true; + } + + if (!exporterTask.Wait(timeoutMilliseconds)) + { + exporter = null!; + return false; + } + + exporter = exporterTask.GetAwaiter().GetResult(); + return true; + } + catch + { + exporter = null!; + return false; + } + } +} diff --git a/tests/Braintrust.Sdk.Tests/Api/BraintrustApiClientTest.cs b/tests/Braintrust.Sdk.Tests/Api/BraintrustApiClientTest.cs index 60e7c25..706f63c 100644 --- a/tests/Braintrust.Sdk.Tests/Api/BraintrustApiClientTest.cs +++ b/tests/Braintrust.Sdk.Tests/Api/BraintrustApiClientTest.cs @@ -6,6 +6,7 @@ namespace Braintrust.Sdk.Tests.Api; +[Collection("BraintrustGlobals")] public class BraintrustApiClientTest : IDisposable { private readonly TestHttpMessageHandler _handler; @@ -128,6 +129,44 @@ public async Task ApiException_ThrownOn_HttpError() Assert.Contains("400", exception.Message); } + [Fact] + public async Task UsesApiKeyFromBraintrustEnvFile() + { + var originalCwd = Directory.GetCurrentDirectory(); + var originalApiKey = Environment.GetEnvironmentVariable("BRAINTRUST_API_KEY"); + var tempDir = Directory.CreateTempSubdirectory("braintrust-api-client-env-").FullName; + + try + { + Environment.SetEnvironmentVariable("BRAINTRUST_API_KEY", null); + File.WriteAllText(Path.Combine(tempDir, ".env.braintrust"), "BRAINTRUST_API_KEY=file-api-key\n"); + Directory.SetCurrentDirectory(tempDir); + + using var handler = new TestHttpMessageHandler(); + using var httpClient = new HttpClient(handler) + { + BaseAddress = new Uri("https://test-api.example.com") + }; + + var config = BraintrustConfig.Of( + ("BRAINTRUST_API_URL", "https://test-api.example.com"), + ("BRAINTRUST_DEFAULT_PROJECT_NAME", "test-project") + ); + using var apiClient = new BraintrustApiClient(config, httpClient); + handler.SetResponse(HttpStatusCode.OK, new Project("proj-123", "test-project", "org-456")); + + await apiClient.GetOrCreateProject("test-project"); + + Assert.Equal("Bearer file-api-key", handler.LastRequest?.Headers.Authorization?.ToString()); + } + finally + { + Directory.SetCurrentDirectory(originalCwd); + Environment.SetEnvironmentVariable("BRAINTRUST_API_KEY", originalApiKey); + Directory.Delete(tempDir, recursive: true); + } + } + // Test HttpMessageHandler for mocking HTTP responses private class TestHttpMessageHandler : HttpMessageHandler { diff --git a/tests/Braintrust.Sdk.Tests/Config/BraintrustApiKeyDiscoveryTest.cs b/tests/Braintrust.Sdk.Tests/Config/BraintrustApiKeyDiscoveryTest.cs new file mode 100644 index 0000000..fd2ea9c --- /dev/null +++ b/tests/Braintrust.Sdk.Tests/Config/BraintrustApiKeyDiscoveryTest.cs @@ -0,0 +1,199 @@ +using Braintrust.Sdk.Config; + +namespace Braintrust.Sdk.Tests.Config; + +[Collection("BraintrustGlobals")] +public class BraintrustApiKeyDiscoveryTest : IDisposable +{ + private readonly string _originalCwd; + private readonly string? _originalApiKey; + private readonly string _tempDir; + + public BraintrustApiKeyDiscoveryTest() + { + _originalCwd = Directory.GetCurrentDirectory(); + _originalApiKey = Environment.GetEnvironmentVariable("BRAINTRUST_API_KEY"); + _tempDir = Directory.CreateTempSubdirectory("braintrust-env-").FullName; + Environment.SetEnvironmentVariable("BRAINTRUST_API_KEY", null); + } + + public void Dispose() + { + Directory.SetCurrentDirectory(_originalCwd); + Environment.SetEnvironmentVariable("BRAINTRUST_API_KEY", _originalApiKey); + Directory.Delete(_tempDir, recursive: true); + } + + [Fact] + public async Task FindsApiKeyInNearestParentBraintrustEnv() + { + var nested = Path.Combine(_tempDir, "packages", "app"); + Directory.CreateDirectory(nested); + WriteBraintrustEnv(_tempDir, "BRAINTRUST_API_KEY=parent-key\n"); + Directory.SetCurrentDirectory(nested); + + var config = BraintrustConfig.FromEnvironment(); + + Assert.Equal("parent-key", await config.GetRequiredApiKeyAsync()); + } + + [Fact] + public async Task UsesNearestBraintrustEnvInsteadOfHigherParent() + { + var nested = Path.Combine(_tempDir, "packages", "app"); + var packageDir = Path.GetDirectoryName(nested)!; + Directory.CreateDirectory(nested); + WriteBraintrustEnv(_tempDir, "BRAINTRUST_API_KEY=root-key\n"); + WriteBraintrustEnv(packageDir, "BRAINTRUST_API_KEY=package-key\n"); + Directory.SetCurrentDirectory(nested); + + var config = BraintrustConfig.FromEnvironment(); + + Assert.Equal("package-key", await config.GetRequiredApiKeyAsync()); + } + + [Theory] + [InlineData("OTHER=value\n")] + [InlineData("BRAINTRUST_API_KEY=\" \"\n")] + public async Task StopsAtNearestBraintrustEnvWhenApiKeyIsMissingOrBlank(string nearestContents) + { + var nested = Path.Combine(_tempDir, "packages", "app"); + var packageDir = Path.GetDirectoryName(nested)!; + Directory.CreateDirectory(nested); + WriteBraintrustEnv(_tempDir, "BRAINTRUST_API_KEY=root-key\n"); + WriteBraintrustEnv(packageDir, nearestContents); + Directory.SetCurrentDirectory(nested); + + var config = BraintrustConfig.FromEnvironment(); + var exception = await Assert.ThrowsAsync(() => config.GetRequiredApiKeyAsync()); + + Assert.Contains("BRAINTRUST_API_KEY is required", exception.Message); + } + + [Fact] + public async Task UsesProcessEnvironmentBeforeBraintrustEnv() + { + WriteBraintrustEnv(_tempDir, "BRAINTRUST_API_KEY=file-key\n"); + Environment.SetEnvironmentVariable("BRAINTRUST_API_KEY", "env-key"); + Directory.SetCurrentDirectory(_tempDir); + + var config = BraintrustConfig.FromEnvironment(); + + Assert.Equal("env-key", await config.GetRequiredApiKeyAsync()); + } + + [Fact] + public async Task FallsBackToBraintrustEnvWhenProcessEnvironmentIsBlank() + { + WriteBraintrustEnv(_tempDir, "BRAINTRUST_API_KEY=file-key\n"); + Environment.SetEnvironmentVariable("BRAINTRUST_API_KEY", " "); + Directory.SetCurrentDirectory(_tempDir); + + var config = BraintrustConfig.FromEnvironment(); + + Assert.Equal("file-key", await config.GetRequiredApiKeyAsync()); + } + + [Fact] + public async Task ExplicitApiKeyOverrideWinsOverEnvironmentAndBraintrustEnv() + { + WriteBraintrustEnv(_tempDir, "BRAINTRUST_API_KEY=file-key\n"); + Environment.SetEnvironmentVariable("BRAINTRUST_API_KEY", "env-key"); + Directory.SetCurrentDirectory(_tempDir); + + var config = BraintrustConfig.Of(("BRAINTRUST_API_KEY", "explicit-key")); + + Assert.Equal("explicit-key", await config.GetRequiredApiKeyAsync()); + } + + [Fact] + public async Task ExplicitBlankApiKeyOverrideDoesNotFallBack() + { + WriteBraintrustEnv(_tempDir, "BRAINTRUST_API_KEY=file-key\n"); + Environment.SetEnvironmentVariable("BRAINTRUST_API_KEY", "env-key"); + Directory.SetCurrentDirectory(_tempDir); + + var config = BraintrustConfig.Of(("BRAINTRUST_API_KEY", " ")); + var exception = await Assert.ThrowsAsync(() => config.GetRequiredApiKeyAsync()); + + Assert.Contains("BRAINTRUST_API_KEY is required", exception.Message); + } + + [Fact] + public async Task SearchesCwdAndAtMost64ParentDirectories() + { + var segments = Enumerable.Range(0, 65).Select(i => $"d{i}").ToArray(); + var nested = Path.Combine(new[] { _tempDir }.Concat(segments).ToArray()); + Directory.CreateDirectory(nested); + WriteBraintrustEnv(_tempDir, "BRAINTRUST_API_KEY=too-high\n"); + Directory.SetCurrentDirectory(nested); + + var config = BraintrustConfig.FromEnvironment(); + await Assert.ThrowsAsync(() => config.GetRequiredApiKeyAsync()); + + WriteBraintrustEnv(Path.Combine(_tempDir, segments[0]), "BRAINTRUST_API_KEY=boundary-key\n"); + + Assert.Equal("boundary-key", await config.GetRequiredApiKeyAsync()); + } + + [Fact] + public async Task SupportsDotenvSyntaxWithoutMutatingEnvironment() + { + WriteBraintrustEnv( + _tempDir, + "OTHER=value\nexport BRAINTRUST_API_KEY=\"quoted-key\" # comment\n"); + Directory.SetCurrentDirectory(_tempDir); + + var config = BraintrustConfig.FromEnvironment(); + + Assert.Equal("quoted-key", await config.GetRequiredApiKeyAsync()); + Assert.Null(Environment.GetEnvironmentVariable("OTHER")); + Assert.Null(Environment.GetEnvironmentVariable("BRAINTRUST_API_KEY")); + } + + [Fact] + public async Task UnreadableNearestBraintrustEnvDoesNotCheckHigherParents() + { + var nested = Path.Combine(_tempDir, "packages", "app"); + var packageDir = Path.GetDirectoryName(nested)!; + Directory.CreateDirectory(nested); + WriteBraintrustEnv(_tempDir, "BRAINTRUST_API_KEY=root-key\n"); + Directory.CreateDirectory(Path.Combine(packageDir, ".env.braintrust")); + Directory.SetCurrentDirectory(nested); + + var config = BraintrustConfig.FromEnvironment(); + var exception = await Assert.ThrowsAsync(() => config.GetRequiredApiKeyAsync()); + + Assert.Contains("BRAINTRUST_API_KEY is required", exception.Message); + } + + [Fact] + public async Task CancellationDuringBraintrustEnvLookupIsPropagated() + { + WriteBraintrustEnv(_tempDir, "BRAINTRUST_API_KEY=file-key\n"); + Directory.SetCurrentDirectory(_tempDir); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + var config = BraintrustConfig.FromEnvironment(); + + await Assert.ThrowsAsync(() => + config.GetRequiredApiKeyAsync(cancellation.Token)); + } + + [Fact] + public async Task ConfigCreationDoesNotReadBraintrustEnvUntilApiKeyLookup() + { + Directory.SetCurrentDirectory(_tempDir); + + var config = BraintrustConfig.FromEnvironment(); + WriteBraintrustEnv(_tempDir, "BRAINTRUST_API_KEY=late-file-key\n"); + + Assert.Equal("late-file-key", await config.GetRequiredApiKeyAsync()); + } + + private static void WriteBraintrustEnv(string dir, string contents) + { + File.WriteAllText(Path.Combine(dir, ".env.braintrust"), contents); + } +} diff --git a/tests/Braintrust.Sdk.Tests/Config/BraintrustConfigTest.cs b/tests/Braintrust.Sdk.Tests/Config/BraintrustConfigTest.cs index ef35a48..642a38f 100644 --- a/tests/Braintrust.Sdk.Tests/Config/BraintrustConfigTest.cs +++ b/tests/Braintrust.Sdk.Tests/Config/BraintrustConfigTest.cs @@ -2,6 +2,7 @@ namespace Braintrust.Sdk.Tests.Config; +[Collection("BraintrustGlobals")] public class BraintrustConfigTest { [Fact] @@ -31,9 +32,8 @@ public void ParentUsesProjectId() [Fact] public void RequiresApiKey() { - var exception = Assert.Throws(() => - BraintrustConfig.Of( - ("BRAINTRUST_API_KEY", BaseConfig.NullOverride))); + var config = BraintrustConfig.Of(("BRAINTRUST_API_KEY", BaseConfig.NullOverride)); + var exception = Assert.Throws(() => _ = config.ApiKey); Assert.Contains("BRAINTRUST_API_KEY is required", exception.Message); } diff --git a/tests/Braintrust.Sdk.Tests/Trace/BraintrustTracingTest.cs b/tests/Braintrust.Sdk.Tests/Trace/BraintrustTracingTest.cs index bc894a7..daf069a 100644 --- a/tests/Braintrust.Sdk.Tests/Trace/BraintrustTracingTest.cs +++ b/tests/Braintrust.Sdk.Tests/Trace/BraintrustTracingTest.cs @@ -27,4 +27,23 @@ public void GetActivitySource_ReturnsActivitySource() Assert.NotNull(activitySource); Assert.Equal("braintrust-dotnet", activitySource.Name); } + + [Fact] + public void CreateTracerProvider_DoesNotRequireApiKeyDuringSetup() + { + var originalApiKey = Environment.GetEnvironmentVariable("BRAINTRUST_API_KEY"); + Environment.SetEnvironmentVariable("BRAINTRUST_API_KEY", null); + + try + { + var config = BraintrustConfig.FromEnvironment(); + using var tracerProvider = BraintrustTracing.CreateTracerProvider(config); + + Assert.NotNull(tracerProvider); + } + finally + { + Environment.SetEnvironmentVariable("BRAINTRUST_API_KEY", originalApiKey); + } + } } From ffc1993035befb33a685876740655528723239a6 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Wed, 27 May 2026 12:46:30 +0200 Subject: [PATCH 2/3] clean & test --- .../Config/BraintrustApiKeyDiscovery.cs | 16 +-- src/Braintrust.Sdk/Config/BraintrustConfig.cs | 14 +- .../Trace/BraintrustOtlpAuthHandler.cs | 45 ++++++ src/Braintrust.Sdk/Trace/BraintrustTracing.cs | 36 ++--- .../Trace/LazyBraintrustOtlpTraceExporter.cs | 128 ------------------ .../Config/BraintrustApiKeyDiscoveryTest.cs | 15 ++ .../Trace/BraintrustTracingTest.cs | 19 +++ 7 files changed, 111 insertions(+), 162 deletions(-) create mode 100644 src/Braintrust.Sdk/Trace/BraintrustOtlpAuthHandler.cs delete mode 100644 src/Braintrust.Sdk/Trace/LazyBraintrustOtlpTraceExporter.cs diff --git a/src/Braintrust.Sdk/Config/BraintrustApiKeyDiscovery.cs b/src/Braintrust.Sdk/Config/BraintrustApiKeyDiscovery.cs index b4b7c75..8d5eab8 100644 --- a/src/Braintrust.Sdk/Config/BraintrustApiKeyDiscovery.cs +++ b/src/Braintrust.Sdk/Config/BraintrustApiKeyDiscovery.cs @@ -6,20 +6,17 @@ internal static class BraintrustApiKeyDiscovery private const string BraintrustEnvFileName = ".env.braintrust"; private const string BraintrustApiKeyName = "BRAINTRUST_API_KEY"; - internal static async Task FindInBraintrustEnvFileAsync(CancellationToken cancellationToken = default) + internal static async Task FindInBraintrustEnvFileAsync( + string? searchRoot, + CancellationToken cancellationToken = default) { - string currentDirectory; - try - { - currentDirectory = Directory.GetCurrentDirectory(); - } - catch + if (string.IsNullOrEmpty(searchRoot)) { return null; } var paths = new List(); - for (var dir = currentDirectory; paths.Count <= BraintrustEnvSearchParentLimit; dir = Path.GetDirectoryName(dir)!) + for (var dir = searchRoot; paths.Count <= BraintrustEnvSearchParentLimit; dir = Path.GetDirectoryName(dir)!) { paths.Add(Path.Combine(dir, BraintrustEnvFileName)); @@ -32,11 +29,10 @@ internal static class BraintrustApiKeyDiscovery // Start the reads together, then await nearest-first so a parent file never beats a closer file. var reads = paths - .Select((path, index) => File.ReadAllTextAsync(path, cancellationToken) + .Select(path => File.ReadAllTextAsync(path, cancellationToken) .ContinueWith( task => new { - Index = index, Contents = task.IsCompletedSuccessfully ? task.Result : null, Error = task.IsCompletedSuccessfully ? null diff --git a/src/Braintrust.Sdk/Config/BraintrustConfig.cs b/src/Braintrust.Sdk/Config/BraintrustConfig.cs index 7387b9c..db83008 100644 --- a/src/Braintrust.Sdk/Config/BraintrustConfig.cs +++ b/src/Braintrust.Sdk/Config/BraintrustConfig.cs @@ -13,6 +13,7 @@ public sealed class BraintrustConfig : BaseConfig private readonly bool _hasApiKeyOverride; private readonly string? _apiKeyOverride; + private readonly string? _braintrustEnvSearchRoot; public string ApiKey => GetRequiredApiKeyAsync().GetAwaiter().GetResult(); public string ApiUrl { get; } @@ -44,6 +45,15 @@ public static BraintrustConfig Of(params (string Key, string? Value)[] envOverri private BraintrustConfig(IDictionary envOverrides) : base(envOverrides) { + try + { + _braintrustEnvSearchRoot = Directory.GetCurrentDirectory(); + } + catch + { + _braintrustEnvSearchRoot = null; + } + if (envOverrides.TryGetValue(ApiKeySettingName, out var apiKeyOverride)) { _hasApiKeyOverride = true; @@ -96,7 +106,9 @@ internal async Task GetRequiredApiKeyAsync(CancellationToken cancellatio throw MissingApiKeyException(); } - var apiKey = await BraintrustApiKeyDiscovery.FindInBraintrustEnvFileAsync(cancellationToken) + var apiKey = await BraintrustApiKeyDiscovery.FindInBraintrustEnvFileAsync( + _braintrustEnvSearchRoot, + cancellationToken) .ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(apiKey)) { diff --git a/src/Braintrust.Sdk/Trace/BraintrustOtlpAuthHandler.cs b/src/Braintrust.Sdk/Trace/BraintrustOtlpAuthHandler.cs new file mode 100644 index 0000000..3b150cb --- /dev/null +++ b/src/Braintrust.Sdk/Trace/BraintrustOtlpAuthHandler.cs @@ -0,0 +1,45 @@ +using System.Net.Http.Headers; +using Braintrust.Sdk.Config; + +namespace Braintrust.Sdk.Trace; + +internal sealed class BraintrustOtlpAuthHandler : DelegatingHandler +{ + private readonly BraintrustConfig _config; + + internal BraintrustOtlpAuthHandler(BraintrustConfig config) + { + _config = config; + } + + protected override HttpResponseMessage Send( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + ApplyBraintrustHeadersAsync(request, cancellationToken).GetAwaiter().GetResult(); + return base.Send(request, cancellationToken); + } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + await ApplyBraintrustHeadersAsync(request, cancellationToken).ConfigureAwait(false); + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + + private async Task ApplyBraintrustHeadersAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var apiKey = await _config.GetRequiredApiKeyAsync(cancellationToken).ConfigureAwait(false); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + + var parentValue = _config.GetBraintrustParentValue(); + if (parentValue != null) + { + request.Headers.Remove("x-bt-parent"); + request.Headers.TryAddWithoutValidation("x-bt-parent", parentValue); + } + } +} diff --git a/src/Braintrust.Sdk/Trace/BraintrustTracing.cs b/src/Braintrust.Sdk/Trace/BraintrustTracing.cs index 949b7bd..739679e 100644 --- a/src/Braintrust.Sdk/Trace/BraintrustTracing.cs +++ b/src/Braintrust.Sdk/Trace/BraintrustTracing.cs @@ -1,7 +1,6 @@ using System.Diagnostics; using Braintrust.Sdk.Config; using Microsoft.Extensions.Logging; -using OpenTelemetry; using OpenTelemetry.Exporter; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; @@ -79,12 +78,19 @@ public static void Enable(BraintrustConfig config, TracerProviderBuilder tracerP .AddService(serviceName: OtelServiceName, serviceVersion: InstrumentationVersion)) .AddSource(InstrumentationName) .AddProcessor(spanProcessor) - .AddProcessor(new BatchActivityExportProcessor( - new LazyBraintrustOtlpTraceExporter(config), - maxQueueSize: 2048, - scheduledDelayMilliseconds: 5000, - exporterTimeoutMilliseconds: 30000, - maxExportBatchSize: 512)) + .AddOtlpExporter(otlpOptions => + { + otlpOptions.Protocol = OtlpExportProtocol.HttpProtobuf; + otlpOptions.Endpoint = new Uri($"{config.ApiUrl}{config.TracesPath}"); + otlpOptions.TimeoutMilliseconds = (int)config.RequestTimeout.TotalMilliseconds; + otlpOptions.HttpClientFactory = () => new HttpClient(new BraintrustOtlpAuthHandler(config) + { + InnerHandler = new HttpClientHandler() + }) + { + Timeout = config.RequestTimeout + }; + }) .SetSampler(new AlwaysOnSampler()); } @@ -104,20 +110,4 @@ public static ActivitySource GetActivitySource() return _activitySource.Value; } - internal static string BuildHeaders(BraintrustConfig config, string apiKey) - { - var headers = new List - { - $"Authorization=Bearer {apiKey}" - }; - - // Add parent header if available - var parentValue = config.GetBraintrustParentValue(); - if (parentValue != null) - { - headers.Add($"x-bt-parent={parentValue}"); - } - - return string.Join(",", headers); - } } diff --git a/src/Braintrust.Sdk/Trace/LazyBraintrustOtlpTraceExporter.cs b/src/Braintrust.Sdk/Trace/LazyBraintrustOtlpTraceExporter.cs deleted file mode 100644 index d6d3560..0000000 --- a/src/Braintrust.Sdk/Trace/LazyBraintrustOtlpTraceExporter.cs +++ /dev/null @@ -1,128 +0,0 @@ -using System.Diagnostics; -using System.Reflection; -using Braintrust.Sdk.Config; -using OpenTelemetry; -using OpenTelemetry.Exporter; - -namespace Braintrust.Sdk.Trace; - -internal sealed class LazyBraintrustOtlpTraceExporter : BaseExporter -{ - private static readonly MethodInfo SetParentProviderMethod = - typeof(BaseExporter) - .GetProperty(nameof(ParentProvider))! - .GetSetMethod(nonPublic: true)!; - - private readonly BraintrustConfig _config; - private readonly object _lock = new(); - private Task? _exporterTask; - - internal LazyBraintrustOtlpTraceExporter(BraintrustConfig config) - { - _config = config; - } - - public override ExportResult Export(in Batch batch) - { - try - { - return GetExporterAsync().GetAwaiter().GetResult().Export(batch); - } - catch - { - return ExportResult.Failure; - } - } - - protected override bool OnForceFlush(int timeoutMilliseconds) - { - return TryGetExporter(GetExporterAsync(), timeoutMilliseconds, out var exporter) - && exporter.ForceFlush(timeoutMilliseconds); - } - - protected override bool OnShutdown(int timeoutMilliseconds) - { - Task? exporterTask; - lock (_lock) - { - exporterTask = _exporterTask; - } - - return exporterTask == null - || (TryGetExporter(exporterTask, timeoutMilliseconds, out var exporter) - && exporter.Shutdown(timeoutMilliseconds)); - } - - protected override void Dispose(bool disposing) - { - if (disposing) - { - Task? exporterTask; - lock (_lock) - { - exporterTask = _exporterTask; - } - - if (exporterTask is { IsCompletedSuccessfully: true }) - { - exporterTask.Result.Dispose(); - } - } - - base.Dispose(disposing); - } - - private Task GetExporterAsync() - { - lock (_lock) - { - _exporterTask ??= CreateExporterAsync(); - return _exporterTask; - } - } - - private async Task CreateExporterAsync() - { - var apiKey = await _config.GetRequiredApiKeyAsync().ConfigureAwait(false); - var exporter = new OtlpTraceExporter(new OtlpExporterOptions - { - Protocol = OtlpExportProtocol.HttpProtobuf, - Endpoint = new Uri($"{_config.ApiUrl}{_config.TracesPath}"), - Headers = BraintrustTracing.BuildHeaders(_config, apiKey), - TimeoutMilliseconds = (int)_config.RequestTimeout.TotalMilliseconds - }); - // OpenTelemetry only assigns ParentProvider to the outer exporter, but OtlpTraceExporter - // reads resources from its own ParentProvider during export. - SetParentProviderMethod.Invoke(exporter, new object?[] { ParentProvider }); - return exporter; - } - - private static bool TryGetExporter( - Task exporterTask, - int timeoutMilliseconds, - out OtlpTraceExporter exporter) - { - try - { - if (timeoutMilliseconds == Timeout.Infinite) - { - exporter = exporterTask.GetAwaiter().GetResult(); - return true; - } - - if (!exporterTask.Wait(timeoutMilliseconds)) - { - exporter = null!; - return false; - } - - exporter = exporterTask.GetAwaiter().GetResult(); - return true; - } - catch - { - exporter = null!; - return false; - } - } -} diff --git a/tests/Braintrust.Sdk.Tests/Config/BraintrustApiKeyDiscoveryTest.cs b/tests/Braintrust.Sdk.Tests/Config/BraintrustApiKeyDiscoveryTest.cs index fd2ea9c..7242d02 100644 --- a/tests/Braintrust.Sdk.Tests/Config/BraintrustApiKeyDiscoveryTest.cs +++ b/tests/Braintrust.Sdk.Tests/Config/BraintrustApiKeyDiscoveryTest.cs @@ -192,6 +192,21 @@ public async Task ConfigCreationDoesNotReadBraintrustEnvUntilApiKeyLookup() Assert.Equal("late-file-key", await config.GetRequiredApiKeyAsync()); } + [Fact] + public async Task UsesBraintrustEnvSearchRootFromConfigCreation() + { + var lookupDir = Path.Combine(_tempDir, "lookup"); + Directory.CreateDirectory(lookupDir); + WriteBraintrustEnv(_tempDir, "BRAINTRUST_API_KEY=creation-key\n"); + WriteBraintrustEnv(lookupDir, "BRAINTRUST_API_KEY=lookup-key\n"); + Directory.SetCurrentDirectory(_tempDir); + + var config = BraintrustConfig.FromEnvironment(); + Directory.SetCurrentDirectory(lookupDir); + + Assert.Equal("creation-key", await config.GetRequiredApiKeyAsync()); + } + private static void WriteBraintrustEnv(string dir, string contents) { File.WriteAllText(Path.Combine(dir, ".env.braintrust"), contents); diff --git a/tests/Braintrust.Sdk.Tests/Trace/BraintrustTracingTest.cs b/tests/Braintrust.Sdk.Tests/Trace/BraintrustTracingTest.cs index daf069a..cae98c2 100644 --- a/tests/Braintrust.Sdk.Tests/Trace/BraintrustTracingTest.cs +++ b/tests/Braintrust.Sdk.Tests/Trace/BraintrustTracingTest.cs @@ -46,4 +46,23 @@ public void CreateTracerProvider_DoesNotRequireApiKeyDuringSetup() Environment.SetEnvironmentVariable("BRAINTRUST_API_KEY", originalApiKey); } } + + [Fact] + public void ForceFlushBeforeExport_DoesNotRequireApiKey() + { + var originalApiKey = Environment.GetEnvironmentVariable("BRAINTRUST_API_KEY"); + Environment.SetEnvironmentVariable("BRAINTRUST_API_KEY", null); + + try + { + var config = BraintrustConfig.FromEnvironment(); + using var tracerProvider = BraintrustTracing.CreateTracerProvider(config); + + Assert.True(BraintrustTracing.ForceFlush()); + } + finally + { + Environment.SetEnvironmentVariable("BRAINTRUST_API_KEY", originalApiKey); + } + } } From aadb77bda950e714f87cee63a34643010da05685 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Wed, 27 May 2026 14:58:47 +0200 Subject: [PATCH 3/3] no readme --- README.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/README.md b/README.md index 938f504..a07944f 100644 --- a/README.md +++ b/README.md @@ -62,14 +62,6 @@ Install the dotnet 8 framework - Linux: Follow [these instructions](https://learn.microsoft.com/en-us/dotnet/core/install/linux-ubuntu-install?tabs=dotnet10&pivots=os-linux-ubuntu-2404) - Windows: Follow [these instructions](https://learn.microsoft.com/en-us/dotnet/core/install/windows) -Set `BRAINTRUST_API_KEY` in your environment, or place it in the nearest `.env.braintrust` file: - -```dotenv -BRAINTRUST_API_KEY=your-api-key -``` - -The SDK only reads `BRAINTRUST_API_KEY` from `.env.braintrust`; it does not load other dotenv variables. - ### List All Examples ```bash