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
14 changes: 9 additions & 5 deletions src/Braintrust.Sdk/Api/BraintrustApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -154,15 +154,17 @@ public async Task<OrganizationAndProjectInfo> GetOrCreateProjectAndOrgInfo()

private async Task<LoginResponse> Login()
{
var request = new LoginRequest(_config.ApiKey);
return await PostAsync<LoginRequest, LoginResponse>("/api/apikey/login", request)
var apiKey = await _config.GetRequiredApiKeyAsync().ConfigureAwait(false);
var request = new LoginRequest(apiKey);
return await PostAsync<LoginRequest, LoginResponse>("/api/apikey/login", request, apiKey: apiKey)
.ConfigureAwait(false);
}

private async Task<TResponse> GetAsync<TResponse>(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);
Expand All @@ -172,10 +174,12 @@ private async Task<TResponse> GetAsync<TResponse>(string path, CancellationToken
private async Task<TResponse> PostAsync<TRequest, TResponse>(
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);

Expand Down
3 changes: 2 additions & 1 deletion src/Braintrust.Sdk/Api/Internal/BtqlClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,9 @@ public async Task<IReadOnlyList<IReadOnlyDictionary<string, JsonElement>>> Query

private async Task<BtqlResponse> 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);

Expand Down
167 changes: 167 additions & 0 deletions src/Braintrust.Sdk/Config/BraintrustApiKeyDiscovery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
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<string?> FindInBraintrustEnvFileAsync(
string? searchRoot,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(searchRoot))
{
return null;
}

var paths = new List<string>();
for (var dir = searchRoot; 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 => File.ReadAllTextAsync(path, cancellationToken)
.ContinueWith(
task => new
{
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;
}
}
71 changes: 69 additions & 2 deletions src/Braintrust.Sdk/Config/BraintrustConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ namespace Braintrust.Sdk.Config;
/// </summary>
public sealed class BraintrustConfig : BaseConfig
{
public string ApiKey { get; }
private const string ApiKeySettingName = "BRAINTRUST_API_KEY";

private readonly bool _hasApiKeyOverride;
private readonly string? _apiKeyOverride;
private readonly string? _braintrustEnvSearchRoot;

public string ApiKey => GetRequiredApiKeyAsync().GetAwaiter().GetResult();
public string ApiUrl { get; }
public string AppUrl { get; }
public string TracesPath { get; }
Expand Down Expand Up @@ -39,7 +45,21 @@ public static BraintrustConfig Of(params (string Key, string? Value)[] envOverri

private BraintrustConfig(IDictionary<string, string?> envOverrides) : base(envOverrides)
{
ApiKey = GetRequiredConfig("BRAINTRUST_API_KEY");
try
{
_braintrustEnvSearchRoot = Directory.GetCurrentDirectory();
}
catch
{
_braintrustEnvSearchRoot = null;
}

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");
Expand All @@ -57,6 +77,53 @@ private BraintrustConfig(IDictionary<string, string?> 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<string> 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(
_braintrustEnvSearchRoot,
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.");
}

/// <summary>
/// The parent attribute tells Braintrust where to send otel data.
///
Expand Down
45 changes: 45 additions & 0 deletions src/Braintrust.Sdk/Trace/BraintrustOtlpAuthHandler.cs
Original file line number Diff line number Diff line change
@@ -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<HttpResponseMessage> 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);
}
}
}
24 changes: 7 additions & 17 deletions src/Braintrust.Sdk/Trace/BraintrustTracing.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,14 @@ public static void Enable(BraintrustConfig config, TracerProviderBuilder tracerP
{
otlpOptions.Protocol = OtlpExportProtocol.HttpProtobuf;
otlpOptions.Endpoint = new Uri($"{config.ApiUrl}{config.TracesPath}");
otlpOptions.Headers = BuildHeaders(config);
otlpOptions.TimeoutMilliseconds = (int)config.RequestTimeout.TotalMilliseconds;
otlpOptions.HttpClientFactory = () => new HttpClient(new BraintrustOtlpAuthHandler(config)
{
InnerHandler = new HttpClientHandler()
})
{
Timeout = config.RequestTimeout
};
})
.SetSampler(new AlwaysOnSampler());
}
Expand All @@ -104,20 +110,4 @@ public static ActivitySource GetActivitySource()
return _activitySource.Value;
}

private static string BuildHeaders(BraintrustConfig config)
{
var headers = new List<string>
{
$"Authorization=Bearer {config.ApiKey}"
};

// Add parent header if available
var parentValue = config.GetBraintrustParentValue();
if (parentValue != null)
{
headers.Add($"x-bt-parent={parentValue}");
}

return string.Join(",", headers);
}
}
Loading
Loading