diff --git a/examples/Program.cs b/examples/Program.cs
index e4b2a53..a007e3d 100644
--- a/examples/Program.cs
+++ b/examples/Program.cs
@@ -1,4 +1,36 @@
-// Placeholder for Braintrust SDK examples
-// Add example programs here
+using System;
+using System.Threading;
+using Braintrust.Sdk;
+using Braintrust.Sdk.Trace;
-Console.WriteLine("Braintrust SDK Examples");
+namespace Braintrust.Sdk.Examples;
+
+class SimpleOpenTelemetryExample
+{
+ static void Main(string[] args)
+ {
+ var braintrust = Braintrust.Get();
+ var tracerProvider = braintrust.OpenTelemetryCreate();
+ var activitySource = BraintrustTracing.GetActivitySource();
+
+ using (var activity = activitySource.StartActivity("hello-dotnet"))
+ {
+ if (activity != null)
+ {
+ Console.WriteLine("Performing simple operation...");
+ activity.SetTag("some boolean attribute", true);
+ Thread.Sleep(100); // Not required. This is just to make the span look interesting
+ }
+
+ if (activity != null)
+ {
+ var url = braintrust.ProjectUri()
+ + $"/logs?r={activity.TraceId}&s={activity.SpanId}";
+ Console.WriteLine($"\n\n Example complete! View your data in Braintrust: {url}");
+ }
+ }
+
+ // Dispose the tracer provider to flush any remaining spans
+ tracerProvider?.Dispose();
+ }
+}
diff --git a/examples/README.md b/examples/README.md
new file mode 100644
index 0000000..3e32d80
--- /dev/null
+++ b/examples/README.md
@@ -0,0 +1,61 @@
+# Braintrust SDK Examples
+
+This directory contains example applications demonstrating how to use the Braintrust .NET SDK.
+
+## Prerequisites
+
+Before running these examples, make sure you have:
+
+1. .NET 8.0 SDK installed
+2. A Braintrust account and API key
+3. Environment variables configured (see below)
+
+## Configuration
+
+Set the following environment variables:
+
+```bash
+export BRAINTRUST_API_KEY="your-api-key"
+export BRAINTRUST_DEFAULT_PROJECT_NAME="your-project-name"
+# Or use project ID:
+# export BRAINTRUST_DEFAULT_PROJECT_ID="your-project-id"
+```
+
+## Running the Examples
+
+### Simple OpenTelemetry Example
+
+This example demonstrates basic OpenTelemetry tracing with Braintrust:
+
+```bash
+cd examples
+dotnet run
+```
+
+The example:
+- Initializes the Braintrust SDK
+- Sets up OpenTelemetry tracing
+- Creates a simple span with attributes
+- Prints a URL to view the trace in Braintrust
+
+## Example Code
+
+The main example is in `Program.cs`:
+
+```csharp
+var braintrust = Braintrust.Get();
+var tracerProvider = braintrust.OpenTelemetryCreate();
+var activitySource = BraintrustTracing.GetActivitySource();
+
+using (var activity = activitySource.StartActivity("hello-dotnet"))
+{
+ activity?.SetTag("some boolean attribute", true);
+ // Your code here...
+}
+```
+
+## Next Steps
+
+- Check out the [Braintrust documentation](https://www.braintrust.dev/docs) for more advanced usage
+- Explore the SDK source code in `src/Braintrust.Sdk/`
+- Look at the test files in `tests/Braintrust.Sdk.Tests/` for more examples
diff --git a/src/Braintrust.Sdk/Braintrust.Sdk.csproj b/src/Braintrust.Sdk/Braintrust.Sdk.csproj
index 7ff75c4..572d2b7 100644
--- a/src/Braintrust.Sdk/Braintrust.Sdk.csproj
+++ b/src/Braintrust.Sdk/Braintrust.Sdk.csproj
@@ -16,6 +16,8 @@
+
+
diff --git a/src/Braintrust.Sdk/Braintrust.cs b/src/Braintrust.Sdk/Braintrust.cs
index 1836432..6d731ac 100644
--- a/src/Braintrust.Sdk/Braintrust.cs
+++ b/src/Braintrust.Sdk/Braintrust.cs
@@ -128,48 +128,41 @@ public Uri ProjectUri()
return new Uri($"{Config.AppUrl}/app/{orgAndProject.OrgInfo.Name}/p/{orgAndProject.Project.Name}");
}
- // TODO: Implement when we have BraintrustTracing
- // ///
- // /// Quick start method that sets up global OpenTelemetry with this Braintrust.
- // ///
- // /// If you're looking for more options for configuring Braintrust/OpenTelemetry,
- // /// consult the Enable method.
- // ///
- // public OpenTelemetry OpenTelemetryCreate()
- // {
- // return OpenTelemetryCreate(registerGlobal: true);
- // }
+ ///
+ /// Quick start method that sets up OpenTelemetry with this Braintrust.
+ ///
+ /// If you're looking for more options for configuring Braintrust/OpenTelemetry,
+ /// consult the Enable method.
+ ///
+ public OpenTelemetry.Trace.TracerProvider OpenTelemetryCreate()
+ {
+ return OpenTelemetryCreate(registerGlobal: true);
+ }
- // TODO: Implement when we have BraintrustTracing
- // ///
- // /// Quick start method that sets up OpenTelemetry with this Braintrust.
- // ///
- // /// If you're looking for more options for configuring Braintrust and OpenTelemetry,
- // /// consult the Enable method.
- // ///
- // public OpenTelemetry OpenTelemetryCreate(bool registerGlobal)
- // {
- // return BraintrustTracing.Of(Config, registerGlobal);
- // }
+ ///
+ /// Quick start method that sets up OpenTelemetry with this Braintrust.
+ ///
+ /// If you're looking for more options for configuring Braintrust and OpenTelemetry,
+ /// consult the Enable method.
+ ///
+ public OpenTelemetry.Trace.TracerProvider OpenTelemetryCreate(bool registerGlobal)
+ {
+ return Trace.BraintrustTracing.Of(Config, registerGlobal);
+ }
- // TODO: Implement when we have BraintrustTracing
- // ///
- // /// Add Braintrust to existing OpenTelemetry builders.
- // ///
- // /// This method provides the most options for configuring Braintrust and OpenTelemetry.
- // /// If you're looking for a more user-friendly setup, consult the OpenTelemetryCreate methods.
- // ///
- // /// NOTE: This method should only be invoked once. Enabling Braintrust multiple times is
- // /// unsupported and may lead to undesired behavior.
- // ///
- // public void OpenTelemetryEnable(
- // TracerProviderBuilder tracerProviderBuilder,
- // LoggerProviderBuilder loggerProviderBuilder,
- // MeterProviderBuilder meterProviderBuilder)
- // {
- // BraintrustTracing.Enable(
- // Config, tracerProviderBuilder, loggerProviderBuilder, meterProviderBuilder);
- // }
+ ///
+ /// Add Braintrust to existing OpenTelemetry TracerProviderBuilder.
+ ///
+ /// This method provides the most options for configuring Braintrust and OpenTelemetry.
+ /// If you're looking for a more user-friendly setup, consult the OpenTelemetryCreate methods.
+ ///
+ /// NOTE: This method should only be invoked once. Enabling Braintrust multiple times is
+ /// unsupported and may lead to undesired behavior.
+ ///
+ public void OpenTelemetryEnable(OpenTelemetry.Trace.TracerProviderBuilder tracerProviderBuilder)
+ {
+ Trace.BraintrustTracing.Enable(Config, tracerProviderBuilder);
+ }
// TODO: Implement when we have Eval
// ///
diff --git a/src/Braintrust.Sdk/Config/BraintrustConfig.cs b/src/Braintrust.Sdk/Config/BraintrustConfig.cs
index 5257b1c..4f150a3 100644
--- a/src/Braintrust.Sdk/Config/BraintrustConfig.cs
+++ b/src/Braintrust.Sdk/Config/BraintrustConfig.cs
@@ -85,11 +85,11 @@ private BraintrustConfig(IDictionary envOverrides) : base(envOve
///
public string? GetBraintrustParentValue()
{
- if (DefaultProjectId != null)
+ if (!string.IsNullOrEmpty(DefaultProjectId))
{
return $"project_id:{DefaultProjectId}";
}
- else if (DefaultProjectName != null)
+ else if (!string.IsNullOrEmpty(DefaultProjectName))
{
return $"project_name:{DefaultProjectName}";
}
diff --git a/src/Braintrust.Sdk/Trace/BraintrustContext.cs b/src/Braintrust.Sdk/Trace/BraintrustContext.cs
new file mode 100644
index 0000000..4ab4b1e
--- /dev/null
+++ b/src/Braintrust.Sdk/Trace/BraintrustContext.cs
@@ -0,0 +1,95 @@
+using System;
+using System.Diagnostics;
+using System.Threading;
+
+namespace Braintrust.Sdk.Trace;
+
+///
+/// Context carrier for Braintrust parent relationships (project/experiment IDs).
+/// This is stored in AsyncLocal to propagate parent information through the trace.
+///
+public sealed class BraintrustContext
+{
+ private static readonly AsyncLocal _current = new AsyncLocal();
+
+ public string? ProjectId { get; }
+ public string? ExperimentId { get; }
+
+ private BraintrustContext(string? projectId, string? experimentId)
+ {
+ ProjectId = projectId;
+ ExperimentId = experimentId;
+ }
+
+ ///
+ /// Create a Braintrust context for an experiment.
+ ///
+ public static BraintrustContext OfExperiment(string experimentId)
+ {
+ return new BraintrustContext(null, experimentId);
+ }
+
+ ///
+ /// Create a Braintrust context for a project.
+ ///
+ public static BraintrustContext OfProject(string projectId)
+ {
+ return new BraintrustContext(projectId, null);
+ }
+
+ ///
+ /// Get the current Braintrust context.
+ ///
+ public static BraintrustContext? Current => _current.Value;
+
+ ///
+ /// Get the Braintrust context from the current Activity (span).
+ ///
+ public static BraintrustContext? FromContext(Activity? activity = null)
+ {
+ // Return the current async local value
+ return _current.Value;
+ }
+
+ ///
+ /// Set this context as the current Braintrust context.
+ /// Returns a disposable scope that will restore the previous context.
+ ///
+ public IDisposable MakeCurrent()
+ {
+ var previous = _current.Value;
+ _current.Value = this;
+ return new ContextScope(previous);
+ }
+
+ private class ContextScope : IDisposable
+ {
+ private readonly BraintrustContext? _previous;
+
+ public ContextScope(BraintrustContext? previous)
+ {
+ _previous = previous;
+ }
+
+ public void Dispose()
+ {
+ _current.Value = _previous;
+ }
+ }
+
+ ///
+ /// Get the parent value string for this context (format: "project_id:X" or "experiment_id:Y").
+ ///
+ public string? GetParentValue()
+ {
+ if (ExperimentId != null)
+ {
+ return $"experiment_id:{ExperimentId}";
+ }
+ if (ProjectId != null)
+ {
+ return $"project_id:{ProjectId}";
+ }
+ return null;
+ }
+}
diff --git a/src/Braintrust.Sdk/Trace/BraintrustSpanExporter.cs b/src/Braintrust.Sdk/Trace/BraintrustSpanExporter.cs
new file mode 100644
index 0000000..227ff82
--- /dev/null
+++ b/src/Braintrust.Sdk/Trace/BraintrustSpanExporter.cs
@@ -0,0 +1,42 @@
+using System;
+using System.Diagnostics;
+using Braintrust.Sdk.Config;
+using OpenTelemetry;
+
+namespace Braintrust.Sdk.Trace;
+
+///
+/// Custom span exporter for Braintrust that logs span exports in debug mode.
+///
+/// Note: Unlike the Java SDK, this implementation does not yet support dynamic per-parent
+/// HTTP header routing. The parent information is still included in span attributes,
+/// so the backend can route based on that. Full per-parent header support requires
+/// a more complex implementation with custom HTTP handling.
+///
+internal sealed class BraintrustSpanExporter : BaseExporter
+{
+ private readonly BraintrustConfig _config;
+
+ public BraintrustSpanExporter(BraintrustConfig config)
+ {
+ _config = config ?? throw new ArgumentNullException(nameof(config));
+ }
+
+ public override ExportResult Export(in Batch batch)
+ {
+ if (_config.Debug)
+ {
+ var count = 0;
+ foreach (var activity in batch)
+ {
+ count++;
+ var parent = activity.GetTagItem(BraintrustSpanProcessor.ParentAttributeKey);
+ Console.WriteLine($"[BraintrustSpanExporter] Exporting span: {activity.DisplayName}, parent={parent}");
+ }
+ Console.WriteLine($"[BraintrustSpanExporter] Exported {count} spans");
+ }
+
+ // Return success - the actual export is handled by the OTLP exporter in the pipeline
+ return ExportResult.Success;
+ }
+}
diff --git a/src/Braintrust.Sdk/Trace/BraintrustSpanProcessor.cs b/src/Braintrust.Sdk/Trace/BraintrustSpanProcessor.cs
new file mode 100644
index 0000000..d137a2f
--- /dev/null
+++ b/src/Braintrust.Sdk/Trace/BraintrustSpanProcessor.cs
@@ -0,0 +1,87 @@
+using System;
+using System.Diagnostics;
+using Braintrust.Sdk.Config;
+using OpenTelemetry;
+
+namespace Braintrust.Sdk.Trace;
+
+///
+/// Custom span processor that enriches spans with Braintrust-specific attributes.
+/// Supports parent assignment to projects or experiments.
+///
+internal sealed class BraintrustSpanProcessor : BaseProcessor
+{
+ public const string ParentAttributeKey = "braintrust.parent";
+
+ private readonly BraintrustConfig _config;
+
+ public BraintrustSpanProcessor(BraintrustConfig config)
+ {
+ _config = config ?? throw new ArgumentNullException(nameof(config));
+ }
+
+ public override void OnStart(Activity activity)
+ {
+ // Check if activity already has a parent attribute
+ var existingParent = activity.GetTagItem(ParentAttributeKey);
+ if (existingParent != null)
+ {
+ return;
+ }
+
+ // Check if parent context has Braintrust attributes first
+ var btContext = BraintrustContext.Current;
+ if (btContext != null)
+ {
+ var parentValue = btContext.GetParentValue();
+ if (parentValue != null)
+ {
+ activity.SetTag(ParentAttributeKey, parentValue);
+ if (_config.Debug)
+ {
+ Console.WriteLine($"[BraintrustSpanProcessor] OnStart: set parent {parentValue} from context for span {activity.DisplayName}");
+ }
+ return;
+ }
+ }
+
+ // Get parent from the config if context doesn't have it
+ var configParent = _config.GetBraintrustParentValue();
+ if (configParent != null)
+ {
+ activity.SetTag(ParentAttributeKey, configParent);
+ if (_config.Debug)
+ {
+ Console.WriteLine($"[BraintrustSpanProcessor] OnStart: set parent {configParent} from config for span {activity.DisplayName}");
+ }
+ }
+ }
+
+ public override void OnEnd(Activity activity)
+ {
+ if (_config.Debug)
+ {
+ LogActivityDetails(activity);
+ }
+ }
+
+ private void LogActivityDetails(Activity activity)
+ {
+ var duration = activity.Duration.TotalMilliseconds;
+ Console.WriteLine(
+ $"[BraintrustSpanProcessor] Activity completed: name={activity.DisplayName}, " +
+ $"traceId={activity.TraceId}, spanId={activity.SpanId}, duration={duration:F2}ms");
+
+ // Log tags
+ foreach (var tag in activity.Tags)
+ {
+ Console.WriteLine($" Tag: {tag.Key}={tag.Value}");
+ }
+
+ // Log events
+ foreach (var evt in activity.Events)
+ {
+ Console.WriteLine($" Event: {evt.Name} at {evt.Timestamp}");
+ }
+ }
+}
diff --git a/src/Braintrust.Sdk/Trace/BraintrustTracing.cs b/src/Braintrust.Sdk/Trace/BraintrustTracing.cs
new file mode 100644
index 0000000..5852b94
--- /dev/null
+++ b/src/Braintrust.Sdk/Trace/BraintrustTracing.cs
@@ -0,0 +1,110 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using Braintrust.Sdk.Config;
+using OpenTelemetry;
+using OpenTelemetry.Exporter;
+using OpenTelemetry.Resources;
+using OpenTelemetry.Trace;
+
+namespace Braintrust.Sdk.Trace;
+
+///
+/// Main entry point for Braintrust OpenTelemetry tracing setup.
+///
+public static class BraintrustTracing
+{
+ public const string ParentKey = "braintrust.parent";
+ private const string OtelServiceName = "braintrust-app";
+ private const string InstrumentationName = "braintrust-dotnet";
+
+ private static readonly string InstrumentationVersion =
+ typeof(BraintrustTracing).Assembly.GetName().Version?.ToString() ?? "0.0.1";
+
+ ///
+ /// Quick start method that sets up global OpenTelemetry with Braintrust from environment config.
+ ///
+ public static TracerProvider Quickstart()
+ {
+ var config = BraintrustConfig.FromEnvironment();
+ return Of(config, registerGlobal: true);
+ }
+
+ ///
+ /// Set up OpenTelemetry with Braintrust configuration.
+ ///
+ /// Braintrust configuration
+ /// Whether to register as the global TracerProvider (not used in .NET, kept for API compatibility)
+ public static TracerProvider Of(BraintrustConfig config, bool registerGlobal = false)
+ {
+ var builder = OpenTelemetry.Sdk.CreateTracerProviderBuilder();
+ Enable(config, builder);
+
+ var provider = builder.Build();
+
+ // Note: In .NET, there's no direct equivalent to Java's GlobalOpenTelemetry.set()
+ // The TracerProvider is typically managed through dependency injection or kept as a singleton
+ // Users can store the returned provider and access it as needed
+
+ return provider;
+ }
+
+ ///
+ /// Add Braintrust configuration to an existing TracerProviderBuilder.
+ /// This method provides the most options for configuring Braintrust and OpenTelemetry.
+ ///
+ public static void Enable(BraintrustConfig config, TracerProviderBuilder tracerProviderBuilder)
+ {
+ // Set up resource with service name and version
+ var resource = ResourceBuilder.CreateDefault()
+ .AddService(
+ serviceName: OtelServiceName,
+ serviceVersion: InstrumentationVersion)
+ .Build();
+
+ tracerProviderBuilder
+ .SetResourceBuilder(ResourceBuilder.CreateDefault()
+ .AddService(serviceName: OtelServiceName, serviceVersion: InstrumentationVersion))
+ .AddSource(InstrumentationName)
+ .AddProcessor(new BraintrustSpanProcessor(config))
+ .AddOtlpExporter(otlpOptions =>
+ {
+ // Configure OTLP HTTP exporter to send to Braintrust
+ otlpOptions.Protocol = OtlpExportProtocol.HttpProtobuf;
+ otlpOptions.Endpoint = new Uri($"{config.ApiUrl}{config.TracesPath}");
+ otlpOptions.Headers = BuildHeaders(config);
+ otlpOptions.TimeoutMilliseconds = (int)config.RequestTimeout.TotalMilliseconds;
+ })
+ .SetSampler(new AlwaysOnSampler());
+
+ // TODO: Add per-parent HTTP header routing like Java SDK
+ // Currently parent info is sent via span attributes; full header-based routing
+ // requires custom HTTP handling
+ // TODO: Add shutdown hook for graceful cleanup
+ }
+
+ ///
+ /// Get an ActivitySource for instrumentation. In .NET, use ActivitySource instead of Tracer.
+ ///
+ public static ActivitySource GetActivitySource()
+ {
+ return new ActivitySource(InstrumentationName, InstrumentationVersion);
+ }
+
+ private static string BuildHeaders(BraintrustConfig config)
+ {
+ var headers = new List
+ {
+ $"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);
+ }
+}
diff --git a/tests/Braintrust.Sdk.Tests/Trace/BraintrustContextTest.cs b/tests/Braintrust.Sdk.Tests/Trace/BraintrustContextTest.cs
new file mode 100644
index 0000000..7842191
--- /dev/null
+++ b/tests/Braintrust.Sdk.Tests/Trace/BraintrustContextTest.cs
@@ -0,0 +1,82 @@
+using Braintrust.Sdk.Trace;
+using Xunit;
+
+namespace Braintrust.Sdk.Tests.Trace;
+
+public class BraintrustContextTest
+{
+ [Fact]
+ public void OfExperiment_CreatesContextWithExperimentId()
+ {
+ var context = BraintrustContext.OfExperiment("exp-123");
+
+ Assert.Null(context.ProjectId);
+ Assert.Equal("exp-123", context.ExperimentId);
+ }
+
+ [Fact]
+ public void OfProject_CreatesContextWithProjectId()
+ {
+ var context = BraintrustContext.OfProject("proj-456");
+
+ Assert.Equal("proj-456", context.ProjectId);
+ Assert.Null(context.ExperimentId);
+ }
+
+ [Fact]
+ public void GetParentValue_ReturnsExperimentIdFormat()
+ {
+ var context = BraintrustContext.OfExperiment("exp-123");
+
+ var parentValue = context.GetParentValue();
+
+ Assert.Equal("experiment_id:exp-123", parentValue);
+ }
+
+ [Fact]
+ public void GetParentValue_ReturnsProjectIdFormat()
+ {
+ var context = BraintrustContext.OfProject("proj-456");
+
+ var parentValue = context.GetParentValue();
+
+ Assert.Equal("project_id:proj-456", parentValue);
+ }
+
+ [Fact]
+ public void MakeCurrent_SetsCurrent()
+ {
+ var context = BraintrustContext.OfProject("proj-789");
+
+ using (context.MakeCurrent())
+ {
+ Assert.Equal(context, BraintrustContext.Current);
+ }
+
+ // Context should be restored after dispose
+ Assert.Null(BraintrustContext.Current);
+ }
+
+ [Fact]
+ public void MakeCurrent_RestoresPreviousContext()
+ {
+ var context1 = BraintrustContext.OfProject("proj-1");
+ var context2 = BraintrustContext.OfProject("proj-2");
+
+ using (context1.MakeCurrent())
+ {
+ Assert.Equal(context1, BraintrustContext.Current);
+
+ using (context2.MakeCurrent())
+ {
+ Assert.Equal(context2, BraintrustContext.Current);
+ }
+
+ // Should restore to context1
+ Assert.Equal(context1, BraintrustContext.Current);
+ }
+
+ // Should restore to null
+ Assert.Null(BraintrustContext.Current);
+ }
+}
diff --git a/tests/Braintrust.Sdk.Tests/Trace/BraintrustSpanExporterTest.cs b/tests/Braintrust.Sdk.Tests/Trace/BraintrustSpanExporterTest.cs
new file mode 100644
index 0000000..c3607b8
--- /dev/null
+++ b/tests/Braintrust.Sdk.Tests/Trace/BraintrustSpanExporterTest.cs
@@ -0,0 +1,66 @@
+using System;
+using System.Diagnostics;
+using Braintrust.Sdk.Config;
+using Braintrust.Sdk.Trace;
+using OpenTelemetry;
+using Xunit;
+
+namespace Braintrust.Sdk.Tests.Trace;
+
+public class BraintrustSpanExporterTest : IDisposable
+{
+ private readonly ActivityListener _activityListener;
+
+ public BraintrustSpanExporterTest()
+ {
+ // Set up activity listener so activities are actually created
+ _activityListener = new ActivityListener
+ {
+ ShouldListenTo = source => source.Name == "test-source",
+ Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData
+ };
+ ActivitySource.AddActivityListener(_activityListener);
+ }
+
+ public void Dispose()
+ {
+ _activityListener?.Dispose();
+ }
+ [Fact]
+ public void Export_SucceedsWithEmptyBatch()
+ {
+ var config = BraintrustConfig.Of(
+ "BRAINTRUST_API_KEY", "test-key",
+ "BRAINTRUST_EXPORT_SPANS_IN_MEMORY_FOR_UNIT_TEST", "false"
+ );
+ var exporter = new BraintrustSpanExporter(config);
+
+ var activities = System.Array.Empty();
+ var batch = new Batch(activities, 0);
+
+ var result = exporter.Export(batch);
+
+ Assert.Equal(ExportResult.Success, result);
+ }
+
+ [Fact]
+ public void Export_SucceedsInTestMode()
+ {
+ var config = BraintrustConfig.Of(
+ "BRAINTRUST_API_KEY", "test-key",
+ "BRAINTRUST_EXPORT_SPANS_IN_MEMORY_FOR_UNIT_TEST", "true"
+ );
+ var exporter = new BraintrustSpanExporter(config);
+
+ using var activitySource = new ActivitySource("test-source");
+ using var activity = activitySource.StartActivity("test-span");
+
+ Assert.NotNull(activity);
+ var activities = new[] { activity };
+ var batch = new Batch(activities, 1);
+
+ var result = exporter.Export(batch);
+
+ Assert.Equal(ExportResult.Success, result);
+ }
+}
diff --git a/tests/Braintrust.Sdk.Tests/Trace/BraintrustSpanProcessorTest.cs b/tests/Braintrust.Sdk.Tests/Trace/BraintrustSpanProcessorTest.cs
new file mode 100644
index 0000000..c5336bd
--- /dev/null
+++ b/tests/Braintrust.Sdk.Tests/Trace/BraintrustSpanProcessorTest.cs
@@ -0,0 +1,156 @@
+using System;
+using System.Diagnostics;
+using Braintrust.Sdk.Config;
+using Braintrust.Sdk.Trace;
+using Xunit;
+
+namespace Braintrust.Sdk.Tests.Trace;
+
+public class BraintrustSpanProcessorTest : IDisposable
+{
+ private readonly ActivitySource _activitySource;
+ private readonly ActivityListener _activityListener;
+
+ public BraintrustSpanProcessorTest()
+ {
+ _activitySource = new ActivitySource("test-source");
+
+ // Set up activity listener so activities are actually created
+ _activityListener = new ActivityListener
+ {
+ ShouldListenTo = source => source.Name == "test-source",
+ Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData
+ };
+ ActivitySource.AddActivityListener(_activityListener);
+ }
+
+ public void Dispose()
+ {
+ _activityListener?.Dispose();
+ _activitySource?.Dispose();
+ }
+
+ [Fact]
+ public void OnStart_AddsParentFromContext()
+ {
+ var config = BraintrustConfig.Of(
+ "BRAINTRUST_API_KEY", "test-key"
+ );
+ var processor = new BraintrustSpanProcessor(config);
+
+ var context = BraintrustContext.OfProject("proj-123");
+ using (context.MakeCurrent())
+ using (var activity = _activitySource.StartActivity("test-span"))
+ {
+ Assert.NotNull(activity);
+ processor.OnStart(activity);
+
+ var parent = activity.GetTagItem(BraintrustSpanProcessor.ParentAttributeKey);
+ Assert.Equal("project_id:proj-123", parent);
+ }
+ }
+
+ [Fact]
+ public void OnStart_AddsExperimentParentFromContext()
+ {
+ var config = BraintrustConfig.Of(
+ "BRAINTRUST_API_KEY", "test-key"
+ );
+ var processor = new BraintrustSpanProcessor(config);
+
+ var context = BraintrustContext.OfExperiment("exp-456");
+ using (context.MakeCurrent())
+ using (var activity = _activitySource.StartActivity("test-span"))
+ {
+ Assert.NotNull(activity);
+ processor.OnStart(activity);
+
+ var parent = activity.GetTagItem(BraintrustSpanProcessor.ParentAttributeKey);
+ Assert.Equal("experiment_id:exp-456", parent);
+ }
+ }
+
+ [Fact]
+ public void OnStart_AddsParentFromConfig()
+ {
+ var config = BraintrustConfig.Of(
+ "BRAINTRUST_API_KEY", "test-key",
+ "BRAINTRUST_DEFAULT_PROJECT_ID", "proj-config"
+ );
+ var processor = new BraintrustSpanProcessor(config);
+
+ using (var activity = _activitySource.StartActivity("test-span"))
+ {
+ Assert.NotNull(activity);
+ processor.OnStart(activity);
+
+ var parent = activity.GetTagItem(BraintrustSpanProcessor.ParentAttributeKey);
+ Assert.Equal("project_id:proj-config", parent);
+ }
+ }
+
+ [Fact]
+ public void OnStart_ContextOverridesConfig()
+ {
+ var config = BraintrustConfig.Of(
+ "BRAINTRUST_API_KEY", "test-key",
+ "BRAINTRUST_DEFAULT_PROJECT_ID", "proj-config"
+ );
+ var processor = new BraintrustSpanProcessor(config);
+
+ var context = BraintrustContext.OfProject("proj-context");
+ using (context.MakeCurrent())
+ using (var activity = _activitySource.StartActivity("test-span"))
+ {
+ Assert.NotNull(activity);
+ processor.OnStart(activity);
+
+ var parent = activity.GetTagItem(BraintrustSpanProcessor.ParentAttributeKey);
+ Assert.Equal("project_id:proj-context", parent);
+ }
+ }
+
+ [Fact]
+ public void OnStart_DoesNotOverrideExistingParent()
+ {
+ var config = BraintrustConfig.Of(
+ "BRAINTRUST_API_KEY", "test-key",
+ "BRAINTRUST_DEFAULT_PROJECT_ID", "proj-config"
+ );
+ var processor = new BraintrustSpanProcessor(config);
+
+ using (var activity = _activitySource.StartActivity("test-span"))
+ {
+ Assert.NotNull(activity);
+ // Set parent before processor runs
+ activity.SetTag(BraintrustSpanProcessor.ParentAttributeKey, "project_id:existing");
+
+ processor.OnStart(activity);
+
+ var parent = activity.GetTagItem(BraintrustSpanProcessor.ParentAttributeKey);
+ Assert.Equal("project_id:existing", parent);
+ }
+ }
+
+ [Fact]
+ public void OnStart_NoParent_WhenNoConfigOrContext()
+ {
+ var config = BraintrustConfig.Of(
+ "BRAINTRUST_API_KEY", "test-key",
+ "BRAINTRUST_DEFAULT_PROJECT_ID", "",
+ "BRAINTRUST_DEFAULT_PROJECT_NAME", "",
+ "BRAINTRUST_DEFAULT_EXPERIMENT_ID", "",
+ "BRAINTRUST_DEFAULT_EXPERIMENT_NAME", ""
+ );
+ var processor = new BraintrustSpanProcessor(config);
+
+ using (var activity = _activitySource.StartActivity("test-span"))
+ {
+ Assert.NotNull(activity);
+ processor.OnStart(activity);
+
+ var parent = activity.GetTagItem(BraintrustSpanProcessor.ParentAttributeKey);
+ Assert.Null(parent);
+ }
+ }
+}
diff --git a/tests/Braintrust.Sdk.Tests/Trace/BraintrustTracingTest.cs b/tests/Braintrust.Sdk.Tests/Trace/BraintrustTracingTest.cs
new file mode 100644
index 0000000..97538e9
--- /dev/null
+++ b/tests/Braintrust.Sdk.Tests/Trace/BraintrustTracingTest.cs
@@ -0,0 +1,48 @@
+using System;
+using Braintrust.Sdk.Config;
+using Braintrust.Sdk.Trace;
+using Xunit;
+
+namespace Braintrust.Sdk.Tests.Trace;
+
+public class BraintrustTracingTest
+{
+ [Fact]
+ public void Of_CreatesTracerProvider()
+ {
+ var config = BraintrustConfig.Of(
+ "BRAINTRUST_API_KEY", "test-key",
+ "BRAINTRUST_API_URL", "https://test-api.example.com"
+ );
+
+ using var tracerProvider = BraintrustTracing.Of(config, registerGlobal: false);
+
+ Assert.NotNull(tracerProvider);
+ }
+
+ [Fact]
+ public void GetActivitySource_ReturnsActivitySource()
+ {
+ var activitySource = BraintrustTracing.GetActivitySource();
+
+ Assert.NotNull(activitySource);
+ Assert.Equal("braintrust-dotnet", activitySource.Name);
+ }
+
+ [Fact]
+ public void Quickstart_CreatesTracerProvider()
+ {
+ // Set required environment variables
+ Environment.SetEnvironmentVariable("BRAINTRUST_API_KEY", "test-key");
+
+ try
+ {
+ using var tracerProvider = BraintrustTracing.Quickstart();
+ Assert.NotNull(tracerProvider);
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable("BRAINTRUST_API_KEY", null);
+ }
+ }
+}