This is an automated email from the ASF dual-hosted git repository.

CurtHagenlocher pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-adbc.git


The following commit(s) were added to refs/heads/main by this push:
     new 693c085b7 feat(csharp): make core ADBC and trace listeners 
AOT-compatible to support standalone C# drivers (#4243)
693c085b7 is described below

commit 693c085b761111edc73bdf1d0749cc728ac97bd2
Author: Curt Hagenlocher <[email protected]>
AuthorDate: Fri Apr 24 12:13:31 2026 -0700

    feat(csharp): make core ADBC and trace listeners AOT-compatible to support 
standalone C# drivers (#4243)
    
    Adds net10.0 to the target frameworks for Apache.Arrow.Adbc and the
    trace-listener assembly, with `<IsAotCompatible>true</IsAotCompatible>`
    enabled on that TFM.
    
    - Replace `FileVersionInfo.GetVersionInfo(assembly.Location)` with
    `AssemblyInformationalVersionAttribute` lookup; `Assembly.Location` is
    empty under single-file publish. A guarded `FileVersionInfo` fallback is
    retained for JIT via `RuntimeFeature.IsDynamicCodeSupported`.
    
    - Rewrite `IArrowArrayExtensions.SerializeToJson` to dispatch through
    `Utf8JsonWriter` instead of the reflection-based `JsonSerializer`. Every
    value type `ParseStructArray` can produce is dispatched explicitly.
    `SqlDecimal` (Decimal128) now emits as a JSON number when the declared
    precision is <= 15 and as a string otherwise, replacing the previous
    accidental {IsNull, Value, Precision, ...} shape.
    
    - Rewrite `FileListener.ActivityProcessor` to serialize via a
    source-generated `JsonSerializerContext`. A new
    `OtelAttributesConverter` preserves OpenTelemetry-compatible attribute
    values (string, bool, int64, double, and homogeneous arrays) as native
    JSON; non-OTel values fall back to invariant-culture strings so the
    output doesn't drift by locale.
    
    - `CAdbcDriverExporter.AdbcDriverInit` returns `NotImplemented` rather
    than `InternalError` for unsupported ADBC versions so the importer's
    1.1.0 -> 1.0.0 fallback works.
    
    - Add `InternalsVisibleTo` for Apache.Arrow.Adbc.Testing (the actual
    assembly name; the existing Apache.Arrow.Adbc.Tests entry is stale).
    
    Covered by 27 new golden-output tests for `SerializeToJson`, 14 new
    tests for `OtelAttributesConverter` (including invariant-culture
    verification under a non-English locale) and 4 new tests for the driver
    exporter.
---
 .github/workflows/csharp.yml                       |  50 +++-
 .../src/Apache.Arrow.Adbc/Apache.Arrow.Adbc.csproj |   6 +-
 .../src/Apache.Arrow.Adbc/C/CAdbcDriverExporter.cs |   6 +-
 .../C/CAdbcDriverImporter.Defaults.cs              |   2 +-
 .../src/Apache.Arrow.Adbc/C/CAdbcDriverImporter.cs |  62 +++--
 csharp/src/Apache.Arrow.Adbc/C/Delegates.cs        |   3 +-
 .../Extensions/IArrowArrayExtensions.cs            | 116 +++++++++-
 .../Apache.Arrow.Adbc/Properties/AssemblyInfo.cs   |   1 +
 .../src/Apache.Arrow.Adbc/Tracing/ActivityTrace.cs |  27 ++-
 ...he.Arrow.Adbc.Telemetry.Traces.Listeners.csproj |   6 +-
 .../Listeners/FileListener/ActivityProcessor.cs    |   8 +
 .../FileListener/OtelAttributesConverter.cs        | 204 +++++++++++++++++
 .../Listeners/FileListener/SerializableActivity.cs |   3 +
 .../Listeners/FileListener/TraceJsonContext.cs     |  28 +++
 .../Apache.Arrow.Adbc.Testing.csproj               |   1 +
 .../ExportedDriverRoundTripTests.cs                | 252 +++++++++++++++++++++
 .../SerializeStructToJsonTests.cs                  | 247 ++++++++++++++++++++
 .../FileListener/OtelAttributesConverterTests.cs   | 158 +++++++++++++
 18 files changed, 1145 insertions(+), 35 deletions(-)

diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml
index 71c9392dd..b5f9e0545 100644
--- a/.github/workflows/csharp.yml
+++ b/.github/workflows/csharp.yml
@@ -50,7 +50,7 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        dotnet: ['8.0.x']
+        dotnet: ['8.0.x', '10.0.x']
         os: [ubuntu-latest, windows-2022, macos-15-intel, macos-latest]
     steps:
       - name: Install C#
@@ -68,3 +68,51 @@ jobs:
       - name: Test
         shell: bash
         run: ci/scripts/csharp_test.sh $(pwd)
+
+  # TODO: Create a test fixture driver to use for interop testing as the
+  # real drivers have migrated to https://github.com/adbc-drivers
+
+  # Publishes the Apache driver as a NativeAOT shared library (net10) and
+  # loads it from Python via adbc_driver_manager to catch AOT regressions
+  # (trim-unsafe reflection, missing exports, broken C-ABI marshaling).
+  # Windows-only for now; the smoke test only exercises Windows paths.
+  csharp-aot:
+    name: "C# NativeAOT smoke test (windows-2022)"
+    runs-on: windows-2022
+    # if: ${{ !contains(github.event.pull_request.title, 'WIP') }}
+    if: false
+    timeout-minutes: 30
+    steps:
+      - name: Checkout ADBC
+        uses: actions/checkout@v6
+        with:
+          fetch-depth: 0
+          submodules: recursive
+      - name: Install .NET 10
+        uses: actions/setup-dotnet@v5
+        with:
+          # NativeAOT for our producer requires net10. Using 10.0.x selects
+          # the latest available SDK; preview tags may be needed until GA.
+          dotnet-version: '10.0.x'
+      - name: Setup MSVC (for NativeAOT linker)
+        # Third-party action; activates vcvars64 so ilc's downstream
+        # link.exe step can find link.exe/lib.exe and the Windows SDK.
+        # The existing workflow only uses first-party actions, so flag
+        # this for review before enabling.
+        uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756  # 
v1
+      - name: Publish NativeAOT driver
+        shell: bash
+        run: |
+          dotnet publish \
+            
csharp/src/Drivers/Apache/Apache.Arrow.Adbc.Drivers.Apache.Native/Apache.Arrow.Adbc.Drivers.Apache.Native.csproj
 \
+            -c Release -r win-x64
+      - name: Install Python
+        uses: actions/setup-python@v5
+        with:
+          python-version: '3.11'
+      - name: Install Python dependencies
+        shell: bash
+        run: python -m pip install adbc_driver_manager
+      - name: Run Python smoke test
+        shell: bash
+        run: python 
csharp/src/Drivers/Apache/Apache.Arrow.Adbc.Drivers.Apache.Native/smoke_test.py
diff --git a/csharp/src/Apache.Arrow.Adbc/Apache.Arrow.Adbc.csproj 
b/csharp/src/Apache.Arrow.Adbc/Apache.Arrow.Adbc.csproj
index 1a6e3ec65..5792b1b91 100644
--- a/csharp/src/Apache.Arrow.Adbc/Apache.Arrow.Adbc.csproj
+++ b/csharp/src/Apache.Arrow.Adbc/Apache.Arrow.Adbc.csproj
@@ -1,10 +1,14 @@
 <Project Sdk="Microsoft.NET.Sdk">
 
   <PropertyGroup>
-    <TargetFrameworks>netstandard2.0;net8.0</TargetFrameworks>
+    <TargetFrameworks>netstandard2.0;net8.0;net10.0</TargetFrameworks>
     <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
     <PackageReadmeFile>readme.md</PackageReadmeFile>
   </PropertyGroup>
+
+  <PropertyGroup Condition="'$(TargetFramework)' == 'net10.0'">
+    <IsAotCompatible>true</IsAotCompatible>
+  </PropertyGroup>
   <ItemGroup>
     <PackageReference Include="Apache.Arrow" />
     <PackageReference Include="System.Diagnostics.DiagnosticSource" />
diff --git a/csharp/src/Apache.Arrow.Adbc/C/CAdbcDriverExporter.cs 
b/csharp/src/Apache.Arrow.Adbc/C/CAdbcDriverExporter.cs
index 0accb1338..0aa1d46ce 100644
--- a/csharp/src/Apache.Arrow.Adbc/C/CAdbcDriverExporter.cs
+++ b/csharp/src/Apache.Arrow.Adbc/C/CAdbcDriverExporter.cs
@@ -86,8 +86,8 @@ namespace Apache.Arrow.Adbc.C
         private static unsafe IntPtr StatementExecutePartitionsPtr = 
NativeDelegate<StatementExecutePartitions>.AsNativePointer(ExecuteStatementPartitions);
         private static unsafe IntPtr StatementExecuteSchemaPtr = 
NativeDelegate<StatementExecuteSchema>.AsNativePointer(ExecuteStatementSchema);
         private static unsafe IntPtr StatementNewPtr = 
NativeDelegate<StatementNew>.AsNativePointer(NewStatement);
-        private static unsafe IntPtr StatementReleasePtr = 
NativeDelegate<StatementRelease>.AsNativePointer(ReleaseStatement);
-        private static unsafe IntPtr StatementPreparePtr = 
NativeDelegate<StatementPrepare>.AsNativePointer(PrepareStatement);
+        private static unsafe IntPtr StatementReleasePtr = 
NativeDelegate<StatementFn>.AsNativePointer(ReleaseStatement);
+        private static unsafe IntPtr StatementPreparePtr = 
NativeDelegate<StatementFn>.AsNativePointer(PrepareStatement);
         private static unsafe IntPtr StatementSetSqlQueryPtr = 
NativeDelegate<StatementSetSqlQuery>.AsNativePointer(SetStatementSqlQuery);
         private static unsafe IntPtr StatementSetSubstraitPlanPtr = 
NativeDelegate<StatementSetSubstraitPlan>.AsNativePointer(SetStatementSubstraitPlan);
         private static unsafe IntPtr StatementGetParameterSchemaPtr = 
NativeDelegate<StatementGetParameterSchema>.AsNativePointer(GetStatementParameterSchema);
@@ -98,7 +98,7 @@ namespace Apache.Arrow.Adbc.C
             if (version != AdbcVersion.Version_1_0_0)
             {
                 // TODO: implement support for AdbcVersion.Version_1_1_0
-                return AdbcStatusCode.InternalError;
+                return AdbcStatusCode.NotImplemented;
             }
 
             DriverStub stub = new DriverStub(driver);
diff --git a/csharp/src/Apache.Arrow.Adbc/C/CAdbcDriverImporter.Defaults.cs 
b/csharp/src/Apache.Arrow.Adbc/C/CAdbcDriverImporter.Defaults.cs
index ed2377ba2..9408d299b 100644
--- a/csharp/src/Apache.Arrow.Adbc/C/CAdbcDriverImporter.Defaults.cs
+++ b/csharp/src/Apache.Arrow.Adbc/C/CAdbcDriverImporter.Defaults.cs
@@ -169,7 +169,7 @@ namespace Apache.Arrow.Adbc.C
         }
 
 #if !NET5_0_OR_GREATER
-        private static unsafe IntPtr StatementPrepareDefault = 
NativeDelegate<StatementPrepare>.AsNativePointer(StatementPrepareDefaultImpl);
+        private static unsafe IntPtr StatementPrepareDefault = 
NativeDelegate<StatementFn>.AsNativePointer(StatementPrepareDefaultImpl);
 #else
         private static unsafe delegate* unmanaged<CAdbcStatement*, 
CAdbcError*, AdbcStatusCode> StatementPrepareDefault => 
&StatementPrepareDefaultImpl;
         [UnmanagedCallersOnly]
diff --git a/csharp/src/Apache.Arrow.Adbc/C/CAdbcDriverImporter.cs 
b/csharp/src/Apache.Arrow.Adbc/C/CAdbcDriverImporter.cs
index af66aac3b..040399f8d 100644
--- a/csharp/src/Apache.Arrow.Adbc/C/CAdbcDriverImporter.cs
+++ b/csharp/src/Apache.Arrow.Adbc/C/CAdbcDriverImporter.cs
@@ -79,27 +79,9 @@ namespace Apache.Arrow.Adbc.C
                 }
 
                 AdbcDriverInit init = 
Marshal.GetDelegateForFunctionPointer<AdbcDriverInit>(export);
-                CAdbcDriver driver = new CAdbcDriver();
-                int version;
-                using (CallHelper caller = new CallHelper())
-                {
-                    try
-                    {
-                        caller.Call(init, AdbcVersion.Version_1_1_0, ref 
driver);
-                        version = AdbcVersion.Version_1_1_0;
-                    }
-                    catch (AdbcException e) when (e.Status == 
AdbcStatusCode.NotImplemented)
-                    {
-                        caller.Call(init, AdbcVersion.Version_1_0_0, ref 
driver);
-                        version = AdbcVersion.Version_1_0_0;
-                    }
-
-                    ValidateDriver(ref driver, version);
-
-                    ImportedAdbcDriver result = new 
ImportedAdbcDriver(library, driver, version, canUnload);
-                    library = IntPtr.Zero;
-                    return result;
-                }
+                ImportedAdbcDriver result = LoadFromInit(init, library, 
canUnload);
+                library = IntPtr.Zero;
+                return result;
             }
             finally
             {
@@ -107,6 +89,40 @@ namespace Apache.Arrow.Adbc.C
             }
         }
 
+        /// <summary>
+        /// Loads an <see cref="AdbcDriver"/> from an in-process delegate that 
acts as the driver's
+        /// AdbcDriverInit entry point. Used for round-tripping <see 
cref="CAdbcDriverExporter"/>
+        /// through the importer in tests without producing a native library.
+        /// </summary>
+        internal static AdbcDriver Load(AdbcDriverInit init)
+        {
+            if (init == null) { throw new ArgumentNullException(nameof(init)); 
}
+            return LoadFromInit(init, IntPtr.Zero, canUnload: false);
+        }
+
+        private static ImportedAdbcDriver LoadFromInit(AdbcDriverInit init, 
IntPtr library, bool canUnload)
+        {
+            CAdbcDriver driver = new CAdbcDriver();
+            int version;
+            using (CallHelper caller = new CallHelper())
+            {
+                try
+                {
+                    caller.Call(init, AdbcVersion.Version_1_1_0, ref driver);
+                    version = AdbcVersion.Version_1_1_0;
+                }
+                catch (AdbcException e) when (e.Status == 
AdbcStatusCode.NotImplemented)
+                {
+                    caller.Call(init, AdbcVersion.Version_1_0_0, ref driver);
+                    version = AdbcVersion.Version_1_0_0;
+                }
+
+                ValidateDriver(ref driver, version);
+
+                return new ImportedAdbcDriver(library, driver, version, 
canUnload);
+            }
+        }
+
         private static unsafe void ValidateDriver(ref CAdbcDriver driver, int 
version)
         {
 #if NET5_0_OR_GREATER
@@ -1036,7 +1052,7 @@ namespace Apache.Arrow.Adbc.C
 #if NET5_0_OR_GREATER
                             Driver.StatementPrepare
 #else
-                            
Marshal.GetDelegateForFunctionPointer<StatementPrepare>(Driver.StatementPrepare)
+                            
Marshal.GetDelegateForFunctionPointer<StatementFn>(Driver.StatementPrepare)
 #endif
                             (statement, &caller._error));
                     }
@@ -1412,7 +1428,7 @@ namespace Apache.Arrow.Adbc.C
                 fixed (CAdbcStatement* stmt = &nativeStatement)
                 fixed (CAdbcError* e = &_error)
                 {
-                    
TranslateCode(Marshal.GetDelegateForFunctionPointer<StatementPrepare>(fn)(stmt, 
e));
+                    
TranslateCode(Marshal.GetDelegateForFunctionPointer<StatementFn>(fn)(stmt, e));
                 }
             }
 #endif
diff --git a/csharp/src/Apache.Arrow.Adbc/C/Delegates.cs 
b/csharp/src/Apache.Arrow.Adbc/C/Delegates.cs
index 9469ee58e..feef7f4a7 100644
--- a/csharp/src/Apache.Arrow.Adbc/C/Delegates.cs
+++ b/csharp/src/Apache.Arrow.Adbc/C/Delegates.cs
@@ -66,19 +66,18 @@ namespace Apache.Arrow.Adbc.C
     internal unsafe delegate AdbcStatusCode 
StatementExecuteQuery(CAdbcStatement* statement, CArrowArrayStream* stream, 
long* rows, CAdbcError* error);
     internal unsafe delegate AdbcStatusCode 
StatementExecutePartitions(CAdbcStatement* statement, CArrowSchema* schema, 
CAdbcPartitions* partitions, long* rows, CAdbcError* error);
     internal unsafe delegate AdbcStatusCode 
StatementExecuteSchema(CAdbcStatement* statement, CArrowSchema* stream, 
CAdbcError* error);
+    internal unsafe delegate AdbcStatusCode StatementFn(CAdbcStatement* 
statement, CAdbcError* error);
     internal unsafe delegate AdbcStatusCode 
StatementGetParameterSchema(CAdbcStatement* statement, CArrowSchema* schema, 
CAdbcError* error);
     internal unsafe delegate AdbcStatusCode StatementGetOption(CAdbcStatement* 
statement, byte* name, byte* value, nint* length, CAdbcError* error);
     internal unsafe delegate AdbcStatusCode 
StatementGetOptionBytes(CAdbcStatement* statement, byte* name, byte* value, 
nint* length, CAdbcError* error);
     internal unsafe delegate AdbcStatusCode 
StatementGetOptionDouble(CAdbcStatement* statement, byte* name, double* value, 
CAdbcError* error);
     internal unsafe delegate AdbcStatusCode 
StatementGetOptionInt(CAdbcStatement* statement, byte* name, long* value, 
CAdbcError* error);
     internal unsafe delegate AdbcStatusCode StatementNew(CAdbcConnection* 
connection, CAdbcStatement* statement, CAdbcError* error);
-    internal unsafe delegate AdbcStatusCode StatementPrepare(CAdbcStatement* 
statement, CAdbcError* error);
     internal unsafe delegate AdbcStatusCode StatementSetOption(CAdbcStatement* 
statement, byte* name, byte* value, CAdbcError* error);
     internal unsafe delegate AdbcStatusCode 
StatementSetOptionBytes(CAdbcStatement* statement, byte* name, byte* value, 
nint length, CAdbcError* error);
     internal unsafe delegate AdbcStatusCode 
StatementSetOptionDouble(CAdbcStatement* statement, byte* name, double value, 
CAdbcError* error);
     internal unsafe delegate AdbcStatusCode 
StatementSetOptionInt(CAdbcStatement* statement, byte* name, long value, 
CAdbcError* error);
     internal unsafe delegate AdbcStatusCode 
StatementSetSqlQuery(CAdbcStatement* statement, byte* text, CAdbcError* error);
     internal unsafe delegate AdbcStatusCode 
StatementSetSubstraitPlan(CAdbcStatement* statement, byte* plan, int length, 
CAdbcError* error);
-    internal unsafe delegate AdbcStatusCode StatementRelease(CAdbcStatement* 
statement, CAdbcError* error);
 #endif
 }
diff --git a/csharp/src/Apache.Arrow.Adbc/Extensions/IArrowArrayExtensions.cs 
b/csharp/src/Apache.Arrow.Adbc/Extensions/IArrowArrayExtensions.cs
index fab63f4ef..2eaf75a9c 100644
--- a/csharp/src/Apache.Arrow.Adbc/Extensions/IArrowArrayExtensions.cs
+++ b/csharp/src/Apache.Arrow.Adbc/Extensions/IArrowArrayExtensions.cs
@@ -317,7 +317,121 @@ namespace Apache.Arrow.Adbc.Extensions
         {
             Dictionary<string, object?>? obj = ParseStructArray(structArray, 
index);
 
-            return JsonSerializer.Serialize(obj);
+            using MemoryStream ms = new();
+            using (Utf8JsonWriter writer = new(ms))
+            {
+                WriteJsonValue(writer, obj);
+            }
+            return System.Text.Encoding.UTF8.GetString(ms.ToArray());
+        }
+
+        private static void WriteJsonValue(Utf8JsonWriter writer, object? 
value)
+        {
+            switch (value)
+            {
+                case null:
+                    writer.WriteNullValue();
+                    return;
+                case string s:
+                    writer.WriteStringValue(s);
+                    return;
+                case bool b:
+                    writer.WriteBooleanValue(b);
+                    return;
+                case int i:
+                    writer.WriteNumberValue(i);
+                    return;
+                case long l:
+                    writer.WriteNumberValue(l);
+                    return;
+                case short sh:
+                    writer.WriteNumberValue(sh);
+                    return;
+                case byte by:
+                    writer.WriteNumberValue(by);
+                    return;
+                case sbyte sb:
+                    writer.WriteNumberValue(sb);
+                    return;
+                case uint ui:
+                    writer.WriteNumberValue(ui);
+                    return;
+                case ulong ul:
+                    writer.WriteNumberValue(ul);
+                    return;
+                case ushort us:
+                    writer.WriteNumberValue(us);
+                    return;
+                case float f:
+                    writer.WriteNumberValue(f);
+                    return;
+                case double d:
+                    writer.WriteNumberValue(d);
+                    return;
+                case decimal dec:
+                    writer.WriteNumberValue(dec);
+                    return;
+                case SqlDecimal sqlDec:
+                    // Decimal32/Decimal64 come back as plain `decimal` and 
are handled above.
+                    // Decimal128 values arrive here; SqlDecimal.Precision 
reflects the column's
+                    // declared precision (every row from the same column 
shares it, so schema
+                    // stays stable). Any decimal that fits in 15 significant 
digits round-trips
+                    // cleanly through double, so it's safe to emit as a JSON 
number. Wider
+                    // columns are emitted as strings — consumers must parse 
them deliberately.
+                    if (sqlDec.Precision <= 15)
+                    {
+                        writer.WriteNumberValue(sqlDec.Value);
+                    }
+                    else
+                    {
+                        // SqlDecimal.ToString() formats from the Data array, 
so it handles
+                        // precisions that would overflow SqlDecimal.Value 
(>28 digits).
+                        writer.WriteStringValue(sqlDec.ToString());
+                    }
+                    return;
+                case DateTime dt:
+                    writer.WriteStringValue(dt);
+                    return;
+                case DateTimeOffset dto:
+                    writer.WriteStringValue(dto);
+                    return;
+#if NET6_0_OR_GREATER
+                case TimeOnly time:
+                    // Match System.Text.Json's ISO-ish "HH:mm:ss[.fraction]" 
format.
+                    // FFFFFFF elides trailing zeros and the decimal point 
when zero.
+                    writer.WriteStringValue(time.ToString("HH:mm:ss.FFFFFFF", 
System.Globalization.CultureInfo.InvariantCulture));
+                    return;
+                case DateOnly date:
+                    writer.WriteStringValue(date.ToString("yyyy-MM-dd", 
System.Globalization.CultureInfo.InvariantCulture));
+                    return;
+#endif
+                case Guid g:
+                    writer.WriteStringValue(g);
+                    return;
+                case byte[] bytes:
+                    writer.WriteBase64StringValue(bytes);
+                    return;
+                case IDictionary<string, object?> dict:
+                    writer.WriteStartObject();
+                    foreach (KeyValuePair<string, object?> kv in dict)
+                    {
+                        writer.WritePropertyName(kv.Key);
+                        WriteJsonValue(writer, kv.Value);
+                    }
+                    writer.WriteEndObject();
+                    return;
+                case IEnumerable enumerable:
+                    writer.WriteStartArray();
+                    foreach (object? item in enumerable)
+                    {
+                        WriteJsonValue(writer, item);
+                    }
+                    writer.WriteEndArray();
+                    return;
+                default:
+                    writer.WriteStringValue(value.ToString());
+                    return;
+            }
         }
 
         /// <summary>
diff --git a/csharp/src/Apache.Arrow.Adbc/Properties/AssemblyInfo.cs 
b/csharp/src/Apache.Arrow.Adbc/Properties/AssemblyInfo.cs
index f9fe442ad..dc9da6037 100644
--- a/csharp/src/Apache.Arrow.Adbc/Properties/AssemblyInfo.cs
+++ b/csharp/src/Apache.Arrow.Adbc/Properties/AssemblyInfo.cs
@@ -19,3 +19,4 @@ using System.Runtime.CompilerServices;
 [assembly: InternalsVisibleTo("Apache.Arrow.Adbc.Drivers.BigQuery, 
PublicKey=0024000004800000940000000602000000240000525341310004000001000100e504183f6d470d6b67b6d19212be3e1f598f70c246a120194bc38130101d0c1853e4a0f2232cb12e37a7a90e707aabd38511dac4f25fcb0d691b2aa265900bf42de7f70468fc997551a40e1e0679b605aa2088a4a69e07c117e988f5b1738c570ee66997fba02485e7856a49eca5fd0706d09899b8312577cbb9034599fc92d4")]
 [assembly: 
InternalsVisibleTo("Apache.Arrow.Adbc.Tests.Drivers.Interop.FlightSql, 
PublicKey=0024000004800000940000000602000000240000525341310004000001000100e504183f6d470d6b67b6d19212be3e1f598f70c246a120194bc38130101d0c1853e4a0f2232cb12e37a7a90e707aabd38511dac4f25fcb0d691b2aa265900bf42de7f70468fc997551a40e1e0679b605aa2088a4a69e07c117e988f5b1738c570ee66997fba02485e7856a49eca5fd0706d09899b8312577cbb9034599fc92d4")]
 [assembly: InternalsVisibleTo("Apache.Arrow.Adbc.Tests, 
PublicKey=0024000004800000940000000602000000240000525341310004000001000100e504183f6d470d6b67b6d19212be3e1f598f70c246a120194bc38130101d0c1853e4a0f2232cb12e37a7a90e707aabd38511dac4f25fcb0d691b2aa265900bf42de7f70468fc997551a40e1e0679b605aa2088a4a69e07c117e988f5b1738c570ee66997fba02485e7856a49eca5fd0706d09899b8312577cbb9034599fc92d4")]
+[assembly: InternalsVisibleTo("Apache.Arrow.Adbc.Testing, 
PublicKey=0024000004800000940000000602000000240000525341310004000001000100e504183f6d470d6b67b6d19212be3e1f598f70c246a120194bc38130101d0c1853e4a0f2232cb12e37a7a90e707aabd38511dac4f25fcb0d691b2aa265900bf42de7f70468fc997551a40e1e0679b605aa2088a4a69e07c117e988f5b1738c570ee66997fba02485e7856a49eca5fd0706d09899b8312577cbb9034599fc92d4")]
diff --git a/csharp/src/Apache.Arrow.Adbc/Tracing/ActivityTrace.cs 
b/csharp/src/Apache.Arrow.Adbc/Tracing/ActivityTrace.cs
index b332590a5..273fa930c 100644
--- a/csharp/src/Apache.Arrow.Adbc/Tracing/ActivityTrace.cs
+++ b/csharp/src/Apache.Arrow.Adbc/Tracing/ActivityTrace.cs
@@ -18,6 +18,8 @@
 using System;
 using System.Collections.Generic;
 using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Reflection;
 using System.Runtime.CompilerServices;
 using System.Threading.Tasks;
 
@@ -40,8 +42,7 @@ namespace Apache.Arrow.Adbc.Tracing
         public ActivityTrace(string? activitySourceName = default, string? 
activitySourceVersion = default, string? traceParent = default, 
IEnumerable<KeyValuePair<string, object?>>? tags = default)
         {
             activitySourceName ??= GetType().Assembly.GetName().Name!;
-            // It's okay to have a null version.
-            activitySourceVersion ??= 
FileVersionInfo.GetVersionInfo(GetType().Assembly.Location).ProductVersion;
+            activitySourceVersion ??= 
GetAssemblyVersion(typeof(ActivityTrace));
             if (string.IsNullOrWhiteSpace(activitySourceName))
             {
                 throw new ArgumentNullException(nameof(activitySourceName));
@@ -263,6 +264,28 @@ namespace Apache.Arrow.Adbc.Tracing
             ActivitySource.Dispose();
         }
 
+        /// <summary>
+        /// If possible, gets the file version for the assembly associated 
with the given Type.
+        /// </summary>
+        [SuppressMessage("SingleFile", "IL3000", Justification="Using guard")]
+        public static string GetAssemblyVersion(Type type)
+        {
+            var versionAttr = 
type.Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
+            if (versionAttr?.InformationalVersion != null) return 
versionAttr.InformationalVersion;
+
+#if NET8_0_OR_GREATER
+            if (RuntimeFeature.IsDynamicCodeSupported && 
!string.IsNullOrEmpty(type.Assembly.Location))
+            {
+                var fileVersion = 
FileVersionInfo.GetVersionInfo(type.Assembly.Location).ProductVersion;
+                if (fileVersion != null) return fileVersion;
+            }
+#endif
+
+            string? assemblyVersion = 
type.Assembly.GetName().Version?.ToString();
+
+            return assemblyVersion ?? string.Empty;
+        }
+
         private static void WriteTraceException(Exception exception, Activity? 
activity)
         {
             activity?.AddException(exception);
diff --git 
a/csharp/src/Telemetry/Traces/Listeners/Apache.Arrow.Adbc.Telemetry.Traces.Listeners.csproj
 
b/csharp/src/Telemetry/Traces/Listeners/Apache.Arrow.Adbc.Telemetry.Traces.Listeners.csproj
index d45d32725..34e77b48e 100644
--- 
a/csharp/src/Telemetry/Traces/Listeners/Apache.Arrow.Adbc.Telemetry.Traces.Listeners.csproj
+++ 
b/csharp/src/Telemetry/Traces/Listeners/Apache.Arrow.Adbc.Telemetry.Traces.Listeners.csproj
@@ -1,10 +1,14 @@
 <Project Sdk="Microsoft.NET.Sdk">
 
   <PropertyGroup>
-    <TargetFrameworks>netstandard2.0;net8.0</TargetFrameworks>
+    <TargetFrameworks>netstandard2.0;net8.0;net10.0</TargetFrameworks>
     <Nullable>enable</Nullable>
   </PropertyGroup>
 
+  <PropertyGroup Condition="'$(TargetFramework)' == 'net10.0'">
+    <IsAotCompatible>true</IsAotCompatible>
+  </PropertyGroup>
+
   <ItemGroup>
     <PackageReference Include="System.Diagnostics.DiagnosticSource" />
     <PackageReference Include="System.Text.Json" />
diff --git 
a/csharp/src/Telemetry/Traces/Listeners/FileListener/ActivityProcessor.cs 
b/csharp/src/Telemetry/Traces/Listeners/FileListener/ActivityProcessor.cs
index 20fff5840..00f6516db 100644
--- a/csharp/src/Telemetry/Traces/Listeners/FileListener/ActivityProcessor.cs
+++ b/csharp/src/Telemetry/Traces/Listeners/FileListener/ActivityProcessor.cs
@@ -89,9 +89,17 @@ namespace 
Apache.Arrow.Adbc.Telemetry.Traces.Listeners.FileListener
 
                     stream.SetLength(0);
                     SerializableActivity serializableActivity = new(activity);
+#if NET6_0_OR_GREATER
+                    await JsonSerializer.SerializeAsync(
+                        stream,
+                        serializableActivity,
+                        TraceJsonContext.Default.SerializableActivity,
+                        cancellationToken).ConfigureAwait(false);
+#else
                     await JsonSerializer.SerializeAsync(
                         stream,
                         serializableActivity, cancellationToken: 
cancellationToken).ConfigureAwait(false);
+#endif
                     stream.Write(s_newLine, 0, s_newLine.Length);
                     stream.Position = 0;
 
diff --git 
a/csharp/src/Telemetry/Traces/Listeners/FileListener/OtelAttributesConverter.cs 
b/csharp/src/Telemetry/Traces/Listeners/FileListener/OtelAttributesConverter.cs
new file mode 100644
index 000000000..54ff69739
--- /dev/null
+++ 
b/csharp/src/Telemetry/Traces/Listeners/FileListener/OtelAttributesConverter.cs
@@ -0,0 +1,204 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Apache.Arrow.Adbc.Telemetry.Traces.Listeners.FileListener
+{
+    /// <summary>
+    /// Writes OTel-compatible attribute values as native JSON. The 
OpenTelemetry spec allows
+    /// attribute values to be <c>string</c>, <c>bool</c>, <c>long</c>, or 
<c>double</c>, or
+    /// homogeneous arrays of those types. Values of those types are emitted 
as native JSON
+    /// scalars/arrays. Anything else (DateTime, custom types, etc.) falls 
through to an
+    /// invariant-culture string representation so the serialized form doesn't 
vary by locale.
+    /// </summary>
+    internal static class OtelAttributeWriter
+    {
+        public static void WriteValue(Utf8JsonWriter writer, object? value)
+        {
+            switch (value)
+            {
+                case null:
+                    writer.WriteNullValue();
+                    return;
+                case string s:
+                    writer.WriteStringValue(s);
+                    return;
+                case bool b:
+                    writer.WriteBooleanValue(b);
+                    return;
+                // OTel's integer attributes are int64. Any integral type up 
to long fits.
+                case long l:
+                    writer.WriteNumberValue(l);
+                    return;
+                case int i:
+                    writer.WriteNumberValue(i);
+                    return;
+                case short sh:
+                    writer.WriteNumberValue(sh);
+                    return;
+                case byte by:
+                    writer.WriteNumberValue(by);
+                    return;
+                case sbyte sb:
+                    writer.WriteNumberValue(sb);
+                    return;
+                case uint ui:
+                    writer.WriteNumberValue(ui);
+                    return;
+                case ulong ul:
+                    writer.WriteNumberValue(ul);
+                    return;
+                case ushort us:
+                    writer.WriteNumberValue(us);
+                    return;
+                // OTel floating-point is double; float widens without loss.
+                case double d:
+                    writer.WriteNumberValue(d);
+                    return;
+                case float f:
+                    writer.WriteNumberValue(f);
+                    return;
+                // Homogeneous OTel arrays. Iterate with the concrete element 
type so each
+                // scalar takes the native-JSON path rather than the fallback.
+                case string?[] ss:
+                    WriteArray(writer, ss);
+                    return;
+                case bool[] bs:
+                    WriteArray(writer, bs);
+                    return;
+                case long[] ls:
+                    WriteArray(writer, ls);
+                    return;
+                case int[] iarr:
+                    WriteArray(writer, iarr);
+                    return;
+                case double[] ds:
+                    WriteArray(writer, ds);
+                    return;
+                // Non-OTel types: invariant-culture string. This keeps the 
output
+                // portable across locales for things like DateTime or custom 
structs.
+                case IFormattable formattable:
+                    writer.WriteStringValue(formattable.ToString(null, 
CultureInfo.InvariantCulture));
+                    return;
+                // Generic enumerable fallback (covers ImmutableArray, 
List<T>, etc.) —
+                // each element is dispatched recursively.
+                case IEnumerable enumerable:
+                    writer.WriteStartArray();
+                    foreach (object? item in enumerable)
+                    {
+                        WriteValue(writer, item);
+                    }
+                    writer.WriteEndArray();
+                    return;
+                default:
+                    writer.WriteStringValue(value.ToString());
+                    return;
+            }
+        }
+
+        private static void WriteArray(Utf8JsonWriter writer, string?[] array)
+        {
+            writer.WriteStartArray();
+            foreach (string? item in array)
+            {
+                if (item == null) { writer.WriteNullValue(); } else { 
writer.WriteStringValue(item); }
+            }
+            writer.WriteEndArray();
+        }
+
+        private static void WriteArray(Utf8JsonWriter writer, bool[] array)
+        {
+            writer.WriteStartArray();
+            foreach (bool item in array) { writer.WriteBooleanValue(item); }
+            writer.WriteEndArray();
+        }
+
+        private static void WriteArray(Utf8JsonWriter writer, long[] array)
+        {
+            writer.WriteStartArray();
+            foreach (long item in array) { writer.WriteNumberValue(item); }
+            writer.WriteEndArray();
+        }
+
+        private static void WriteArray(Utf8JsonWriter writer, int[] array)
+        {
+            writer.WriteStartArray();
+            foreach (int item in array) { writer.WriteNumberValue(item); }
+            writer.WriteEndArray();
+        }
+
+        private static void WriteArray(Utf8JsonWriter writer, double[] array)
+        {
+            writer.WriteStartArray();
+            foreach (double item in array) { writer.WriteNumberValue(item); }
+            writer.WriteEndArray();
+        }
+    }
+
+    /// <summary>
+    /// Write-only converter for <see cref="SerializableActivity.TagObjects"/> 
and similar
+    /// <c>IReadOnlyDictionary&lt;string, object?&gt;</c> properties. Emits a 
JSON object.
+    /// </summary>
+    internal sealed class OtelAttributesDictionaryConverter : 
JsonConverter<IReadOnlyDictionary<string, object?>>
+    {
+        public override IReadOnlyDictionary<string, object?>? Read(ref 
Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+            => throw new NotSupportedException("Reading SerializableActivity 
from JSON is not supported.");
+
+        public override void Write(Utf8JsonWriter writer, 
IReadOnlyDictionary<string, object?> value, JsonSerializerOptions options)
+        {
+            writer.WriteStartObject();
+            foreach (KeyValuePair<string, object?> kv in value)
+            {
+                writer.WritePropertyName(kv.Key);
+                OtelAttributeWriter.WriteValue(writer, kv.Value);
+            }
+            writer.WriteEndObject();
+        }
+    }
+
+    /// <summary>
+    /// Write-only converter for <c>IReadOnlyList&lt;KeyValuePair&lt;string, 
object?&gt;&gt;</c>
+    /// properties (ActivityEvent.Tags, ActivityLink.Tags). Emits a JSON array 
of
+    /// <c>{"Key": ..., "Value": ...}</c> objects to match the shape produced 
by the
+    /// previous reflection-based JsonSerializer.
+    /// </summary>
+    internal sealed class OtelAttributesListConverter : 
JsonConverter<IReadOnlyList<KeyValuePair<string, object?>>>
+    {
+        public override IReadOnlyList<KeyValuePair<string, object?>>? Read(ref 
Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+            => throw new NotSupportedException("Reading SerializableActivity 
from JSON is not supported.");
+
+        public override void Write(Utf8JsonWriter writer, 
IReadOnlyList<KeyValuePair<string, object?>> value, JsonSerializerOptions 
options)
+        {
+            writer.WriteStartArray();
+            foreach (KeyValuePair<string, object?> kv in value)
+            {
+                writer.WriteStartObject();
+                writer.WriteString("Key", kv.Key);
+                writer.WritePropertyName("Value");
+                OtelAttributeWriter.WriteValue(writer, kv.Value);
+                writer.WriteEndObject();
+            }
+            writer.WriteEndArray();
+        }
+    }
+}
diff --git 
a/csharp/src/Telemetry/Traces/Listeners/FileListener/SerializableActivity.cs 
b/csharp/src/Telemetry/Traces/Listeners/FileListener/SerializableActivity.cs
index 2af071a68..c3465861c 100644
--- a/csharp/src/Telemetry/Traces/Listeners/FileListener/SerializableActivity.cs
+++ b/csharp/src/Telemetry/Traces/Listeners/FileListener/SerializableActivity.cs
@@ -122,6 +122,7 @@ namespace 
Apache.Arrow.Adbc.Telemetry.Traces.Listeners.FileListener
         public string? ParentSpanId { get; set; }
         public string? IdFormat { get; set; }
 
+        [JsonConverter(typeof(OtelAttributesDictionaryConverter))]
         public IReadOnlyDictionary<string, object?> TagObjects { get; set; } = 
new Dictionary<string, object?>();
         public IReadOnlyList<SerializableActivityEvent> Events { get; set; } = 
[];
         public IReadOnlyList<SerializableActivityLink> Links { get; set; } = 
[];
@@ -140,6 +141,7 @@ namespace 
Apache.Arrow.Adbc.Telemetry.Traces.Listeners.FileListener
         /// </summary>
         public DateTimeOffset Timestamp { get; set; }
 
+        [JsonConverter(typeof(OtelAttributesListConverter))]
         public IReadOnlyList<KeyValuePair<string, object?>> Tags { get; set; } 
= [];
 
         public static implicit operator 
SerializableActivityEvent(ActivityEvent source)
@@ -157,6 +159,7 @@ namespace 
Apache.Arrow.Adbc.Telemetry.Traces.Listeners.FileListener
     {
         public SerializableActivityContext? Context { get; set; }
 
+        [JsonConverter(typeof(OtelAttributesListConverter))]
         public IReadOnlyList<KeyValuePair<string, object?>>? Tags { get; set; 
} = [];
 
         public static implicit operator SerializableActivityLink(ActivityLink 
source)
diff --git 
a/csharp/src/Telemetry/Traces/Listeners/FileListener/TraceJsonContext.cs 
b/csharp/src/Telemetry/Traces/Listeners/FileListener/TraceJsonContext.cs
new file mode 100644
index 000000000..ae0c0d7ba
--- /dev/null
+++ b/csharp/src/Telemetry/Traces/Listeners/FileListener/TraceJsonContext.cs
@@ -0,0 +1,28 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#if NET6_0_OR_GREATER
+using System.Text.Json.Serialization;
+
+namespace Apache.Arrow.Adbc.Telemetry.Traces.Listeners.FileListener
+{
+    [JsonSerializable(typeof(SerializableActivity))]
+    internal partial class TraceJsonContext : JsonSerializerContext
+    {
+    }
+}
+#endif
diff --git 
a/csharp/test/Apache.Arrow.Adbc.Tests/Apache.Arrow.Adbc.Testing.csproj 
b/csharp/test/Apache.Arrow.Adbc.Tests/Apache.Arrow.Adbc.Testing.csproj
index ce19532df..05f9896d7 100644
--- a/csharp/test/Apache.Arrow.Adbc.Tests/Apache.Arrow.Adbc.Testing.csproj
+++ b/csharp/test/Apache.Arrow.Adbc.Tests/Apache.Arrow.Adbc.Testing.csproj
@@ -5,6 +5,7 @@
     <TargetFrameworks 
Condition="'$(TargetFrameworks)'==''">net8.0</TargetFrameworks>
     <IsPackable>true</IsPackable>
     <IsTestProject>true</IsTestProject>
+    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
     
<ProcessArchitecture>$([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString().ToLowerInvariant())</ProcessArchitecture>
   </PropertyGroup>
 
diff --git 
a/csharp/test/Apache.Arrow.Adbc.Tests/ExportedDriverRoundTripTests.cs 
b/csharp/test/Apache.Arrow.Adbc.Tests/ExportedDriverRoundTripTests.cs
new file mode 100644
index 000000000..071e8805e
--- /dev/null
+++ b/csharp/test/Apache.Arrow.Adbc.Tests/ExportedDriverRoundTripTests.cs
@@ -0,0 +1,252 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Apache.Arrow.Adbc.C;
+using Apache.Arrow.Ipc;
+using Apache.Arrow.Types;
+using Xunit;
+
+namespace Apache.Arrow.Adbc.Tests
+{
+    /// <summary>
+    /// Runs a managed <see cref="AdbcDriver"/> fixture through
+    /// <see cref="CAdbcDriverExporter"/> to populate a CAdbcDriver struct,
+    /// then loads that struct via <see cref="CAdbcDriverImporter"/> as if it
+    /// came from a native library. Exercises the ADBC 1.0 C-ABI marshaling
+    /// without producing or loading a native DLL.
+    /// </summary>
+    public class ExportedDriverRoundTripTests
+    {
+        [Fact]
+        public async Task RoundTripSimpleQuery()
+        {
+            var fixture = new FixtureDriver();
+
+            using AdbcDriver imported = 
CAdbcDriverImporter.Load(CreateAdapter(fixture));
+
+            using AdbcDatabase db = imported.Open(new Dictionary<string, 
string> { { "uri", "ignored" } });
+            using AdbcConnection conn = db.Connect(null);
+            using AdbcStatement stmt = conn.CreateStatement();
+
+            stmt.SqlQuery = "SELECT 42";
+            QueryResult result = stmt.ExecuteQuery();
+
+            using IArrowArrayStream stream = result.Stream!;
+            Assert.NotNull(stream);
+
+            Schema schema = stream.Schema;
+            Assert.Single(schema.FieldsList);
+            Assert.Equal(ArrowTypeId.Int32, 
schema.FieldsList[0].DataType.TypeId);
+            Assert.Equal("answer", schema.FieldsList[0].Name);
+
+            RecordBatch? batch = await stream.ReadNextRecordBatchAsync();
+            Assert.NotNull(batch);
+            Assert.Equal(1, batch!.Length);
+            Int32Array column = Assert.IsType<Int32Array>(batch.Column(0));
+            Assert.Equal(42, column.Values[0]);
+
+            Assert.Null(await stream.ReadNextRecordBatchAsync());
+        }
+
+        [Fact]
+        public void SqlQueryIsMarshaledToProducer()
+        {
+            var fixture = new FixtureDriver();
+
+            using AdbcDriver imported = 
CAdbcDriverImporter.Load(CreateAdapter(fixture));
+            using AdbcDatabase db = imported.Open(new Dictionary<string, 
string>());
+            using AdbcConnection conn = db.Connect(null);
+            using AdbcStatement stmt = conn.CreateStatement();
+
+            const string query = "SELECT 'hello', 'world'";
+            stmt.SqlQuery = query;
+            stmt.ExecuteQuery();
+
+            Assert.Equal(query, fixture.LastStatement!.ReceivedQuery);
+        }
+
+        [Fact]
+        public void OpenParametersAreMarshaledToProducer()
+        {
+            var fixture = new FixtureDriver();
+
+            using AdbcDriver imported = 
CAdbcDriverImporter.Load(CreateAdapter(fixture));
+            using AdbcDatabase db = imported.Open(new Dictionary<string, 
string>
+            {
+                { "first", "1" },
+                { "second", "2" },
+            });
+
+            Assert.Equal("1", fixture.LastDatabase!.Options["first"]);
+            Assert.Equal("2", fixture.LastDatabase!.Options["second"]);
+        }
+
+        [Fact]
+        public void ProducerExceptionPropagatesAsAdbcException()
+        {
+            var fixture = new FixtureDriver { ThrowOnExecute = new 
InvalidOperationException("boom") };
+
+            using AdbcDriver imported = 
CAdbcDriverImporter.Load(CreateAdapter(fixture));
+            using AdbcDatabase db = imported.Open(new Dictionary<string, 
string>());
+            using AdbcConnection conn = db.Connect(null);
+            using AdbcStatement stmt = conn.CreateStatement();
+            stmt.SqlQuery = "SELECT 1";
+
+            AdbcException ex = Assert.ThrowsAny<AdbcException>(() => 
stmt.ExecuteQuery());
+            Assert.Contains("boom", ex.Message);
+        }
+
+        private static AdbcDriverInit CreateAdapter(AdbcDriver driver)
+        {
+            return (int version, ref CAdbcDriver nativeDriver, ref CAdbcError 
error) =>
+            {
+                unsafe
+                {
+                    fixed (CAdbcDriver* dp = &nativeDriver)
+                    fixed (CAdbcError* ep = &error)
+                    {
+                        return CAdbcDriverExporter.AdbcDriverInit(version, dp, 
ep, driver);
+                    }
+                }
+            };
+        }
+
+        private sealed class FixtureDriver : AdbcDriver
+        {
+            public FixtureDatabase? LastDatabase { get; private set; }
+            public FixtureStatement? LastStatement { get; private set; }
+            public Exception? ThrowOnExecute { get; set; }
+
+            public override AdbcDatabase Open(IReadOnlyDictionary<string, 
string> parameters)
+            {
+                var db = new FixtureDatabase(this, parameters);
+                LastDatabase = db;
+                return db;
+            }
+
+            internal void RecordStatement(FixtureStatement stmt) => 
LastStatement = stmt;
+        }
+
+        private sealed class FixtureDatabase : AdbcDatabase
+        {
+            private readonly FixtureDriver _driver;
+            public Dictionary<string, string> Options { get; }
+
+            public FixtureDatabase(FixtureDriver driver, 
IReadOnlyDictionary<string, string> parameters)
+            {
+                _driver = driver;
+#if NET6_0_OR_GREATER
+                Options = new Dictionary<string, string>(parameters);
+#else
+                Options = new Dictionary<string, string>(parameters.Count);
+                foreach (KeyValuePair<string, string> pair in parameters)
+                {
+                    Options.Add(pair.Key, pair.Value);
+                }
+#endif
+            }
+
+            public override void SetOption(string key, string value) => 
Options[key] = value;
+
+            public override AdbcConnection Connect(IReadOnlyDictionary<string, 
string>? options)
+                => new FixtureConnection(_driver);
+        }
+
+        private sealed class FixtureConnection : AdbcConnection
+        {
+            private readonly FixtureDriver _driver;
+
+            public FixtureConnection(FixtureDriver driver) { _driver = driver; 
}
+
+            public override AdbcStatement CreateStatement()
+            {
+                var stmt = new FixtureStatement(_driver);
+                _driver.RecordStatement(stmt);
+                return stmt;
+            }
+
+            public override IArrowArrayStream GetObjects(
+                GetObjectsDepth depth, string? catalogPattern, string? 
dbSchemaPattern,
+                string? tableNamePattern, IReadOnlyList<string>? tableTypes, 
string? columnNamePattern)
+                => throw AdbcException.NotImplemented("fixture does not 
support GetObjects");
+
+            public override Schema GetTableSchema(string? catalog, string? 
dbSchema, string tableName)
+                => throw AdbcException.NotImplemented("fixture does not 
support GetTableSchema");
+
+            public override IArrowArrayStream GetTableTypes()
+                => throw AdbcException.NotImplemented("fixture does not 
support GetTableTypes");
+        }
+
+        private sealed class FixtureStatement : AdbcStatement
+        {
+            private readonly FixtureDriver _driver;
+            private string? _sqlQuery;
+
+            public FixtureStatement(FixtureDriver driver) { _driver = driver; }
+
+            public string? ReceivedQuery => _sqlQuery;
+
+            public override string? SqlQuery
+            {
+                get => _sqlQuery;
+                set => _sqlQuery = value;
+            }
+
+            public override QueryResult ExecuteQuery()
+            {
+                if (_driver.ThrowOnExecute != null) { throw 
_driver.ThrowOnExecute; }
+
+                var schema = new Schema.Builder()
+                    .Field(f => 
f.Name("answer").DataType(Int32Type.Default).Nullable(false))
+                    .Build();
+                var column = new Int32Array.Builder().Append(42).Build();
+                var batch = new RecordBatch(schema, new IArrowArray[] { column 
}, 1);
+                return new QueryResult(1, new SingleBatchStream(schema, 
batch));
+            }
+
+            public override UpdateResult ExecuteUpdate()
+                => throw AdbcException.NotImplemented("fixture does not 
support ExecuteUpdate");
+        }
+
+        private sealed class SingleBatchStream : IArrowArrayStream
+        {
+            private readonly Schema _schema;
+            private RecordBatch? _batch;
+
+            public SingleBatchStream(Schema schema, RecordBatch batch)
+            {
+                _schema = schema;
+                _batch = batch;
+            }
+
+            public Schema Schema => _schema;
+
+            public ValueTask<RecordBatch?> 
ReadNextRecordBatchAsync(CancellationToken cancellationToken = default)
+            {
+                RecordBatch? result = _batch;
+                _batch = null;
+                return new ValueTask<RecordBatch?>(result);
+            }
+
+            public void Dispose() { _batch?.Dispose(); _batch = null; }
+        }
+    }
+}
diff --git a/csharp/test/Apache.Arrow.Adbc.Tests/SerializeStructToJsonTests.cs 
b/csharp/test/Apache.Arrow.Adbc.Tests/SerializeStructToJsonTests.cs
new file mode 100644
index 000000000..d5ff7d0bc
--- /dev/null
+++ b/csharp/test/Apache.Arrow.Adbc.Tests/SerializeStructToJsonTests.cs
@@ -0,0 +1,247 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+using System;
+using System.Collections.Generic;
+using System.Data.SqlTypes;
+using Apache.Arrow.Adbc.Extensions;
+using Apache.Arrow.Types;
+using Xunit;
+
+namespace Apache.Arrow.Adbc.Tests
+{
+    /// <summary>
+    /// Golden-output tests for <c>IArrowArrayExtensions.ValueAt(index, 
StructResultType.JsonString)</c>
+    /// — i.e. the private <c>SerializeToJson</c> path. Each test builds a 
single-row <see cref="StructArray"/>
+    /// containing one field of a particular Arrow type, runs it through the 
JSON conversion, and asserts
+    /// the exact string output.
+    /// </summary>
+    public class SerializeStructToJsonTests
+    {
+        private static string SerializeRow(StructArray structArray, int index 
= 0)
+            => (string)structArray.ValueAt(index, 
StructResultType.JsonString)!;
+
+        private static StructArray SingleFieldStruct(string fieldName, 
IArrowType type, IArrowArray values)
+        {
+            var structType = new StructType(new[] { new Field(fieldName, type, 
nullable: true) });
+            return new StructArray(structType, values.Length, new[] { values 
}, new ArrowBuffer.BitmapBuilder().Build());
+        }
+
+        [Fact]
+        public void Bool()
+        {
+            var values = new BooleanArray.Builder().Append(true).Build();
+            var s = SingleFieldStruct("f", BooleanType.Default, values);
+            Assert.Equal("{\"f\":true}", SerializeRow(s));
+        }
+
+        [Fact]
+        public void Int8() => AssertJson("{\"f\":-7}", Int8Type.Default, new 
Int8Array.Builder().Append((sbyte)-7).Build());
+
+        [Fact]
+        public void Int16() => AssertJson("{\"f\":-300}", Int16Type.Default, 
new Int16Array.Builder().Append((short)-300).Build());
+
+        [Fact]
+        public void Int32() => AssertJson("{\"f\":-70000}", Int32Type.Default, 
new Int32Array.Builder().Append(-70000).Build());
+
+        [Fact]
+        public void Int64() => AssertJson("{\"f\":-5000000000}", 
Int64Type.Default, new Int64Array.Builder().Append(-5_000_000_000L).Build());
+
+        [Fact]
+        public void UInt8() => AssertJson("{\"f\":200}", UInt8Type.Default, 
new UInt8Array.Builder().Append((byte)200).Build());
+
+        [Fact]
+        public void UInt16() => AssertJson("{\"f\":60000}", 
UInt16Type.Default, new UInt16Array.Builder().Append((ushort)60000).Build());
+
+        [Fact]
+        public void UInt32() => AssertJson("{\"f\":4000000000}", 
UInt32Type.Default, new UInt32Array.Builder().Append(4_000_000_000u).Build());
+
+        [Fact]
+        public void UInt64() => AssertJson("{\"f\":9000000000000000000}", 
UInt64Type.Default, new 
UInt64Array.Builder().Append(9_000_000_000_000_000_000ul).Build());
+
+        [Fact]
+        public void Float() => AssertJson("{\"f\":1.5}", FloatType.Default, 
new FloatArray.Builder().Append(1.5f).Build());
+
+        [Fact]
+        public void Double() => AssertJson("{\"f\":3.25}", DoubleType.Default, 
new DoubleArray.Builder().Append(3.25).Build());
+
+        [Fact]
+        public void String() => AssertJson("{\"f\":\"hi\"}", 
StringType.Default, new StringArray.Builder().Append("hi").Build());
+
+        [Fact]
+        public void StringWithEscapes()
+            // JsonSerializer / Utf8JsonWriter default encoder escapes '"' as 
\u0022
+            // (relaxed encoder would produce \").
+            => AssertJson("{\"f\":\"a\\u0022b\\nc\"}", StringType.Default, new 
StringArray.Builder().Append("a\"b\nc").Build());
+
+        [Fact]
+        public void Binary()
+        {
+            var values = new BinaryArray.Builder().Append(new byte[] { 0x01, 
0x02, 0x03 }).Build();
+            AssertJson("{\"f\":\"AQID\"}", BinaryType.Default, values);
+        }
+
+        [Fact]
+        public void Date32()
+        {
+            var d = new DateTime(2026, 4, 18, 0, 0, 0, 
DateTimeKind.Unspecified);
+            var values = new Date32Array.Builder().Append(d).Build();
+            AssertJson("{\"f\":\"2026-04-18T00:00:00\"}", Date32Type.Default, 
values);
+        }
+
+        [Fact]
+        public void Date64()
+        {
+            var d = new DateTime(2026, 4, 18, 0, 0, 0, 
DateTimeKind.Unspecified);
+            var values = new Date64Array.Builder().Append(d).Build();
+            AssertJson("{\"f\":\"2026-04-18T00:00:00\"}", Date64Type.Default, 
values);
+        }
+
+        [Fact]
+        public void Timestamp()
+        {
+            var ts = new DateTimeOffset(2026, 4, 18, 12, 34, 56, 
TimeSpan.Zero);
+            var values = new 
TimestampArray.Builder(TimestampType.Default).Append(ts).Build();
+            AssertJson("{\"f\":\"2026-04-18T12:34:56+00:00\"}", 
TimestampType.Default, values);
+        }
+
+        [Fact]
+        public void Time32Seconds()
+        {
+            // 5 minutes past midnight
+            var builder = new Time32Array.Builder(TimeUnit.Second);
+            builder.Append(300);
+            AssertJson("{\"f\":\"00:05:00\"}", new 
Time32Type(TimeUnit.Second), builder.Build());
+        }
+
+        [Fact]
+        public void Time64Microseconds()
+        {
+            var builder = new Time64Array.Builder(TimeUnit.Microsecond);
+            builder.Append(1_000_000L); // 1 second
+            AssertJson("{\"f\":\"00:00:01\"}", new 
Time64Type(TimeUnit.Microsecond), builder.Build());
+        }
+
+        [Fact]
+        public void Decimal32_AsNumber()
+        {
+            var type = new Decimal32Type(9, 2);
+            var builder = new Decimal32Array.Builder(type);
+            builder.Append(12.34m);
+            AssertJson("{\"f\":12.34}", type, builder.Build());
+        }
+
+        [Fact]
+        public void Decimal64_AsNumber()
+        {
+            var type = new Decimal64Type(15, 3);
+            var builder = new Decimal64Array.Builder(type);
+            builder.Append(123456789.012m);
+            AssertJson("{\"f\":123456789.012}", type, builder.Build());
+        }
+
+        [Fact]
+        public void Decimal128_NarrowPrecision_AsNumber()
+        {
+            // Precision <= 15 — every value fits in a double-round-tripping 
decimal,
+            // so we emit a bare JSON number.
+            var type = new Decimal128Type(10, 2);
+            var builder = new Decimal128Array.Builder(type);
+            builder.Append(123.45m);
+            AssertJson("{\"f\":123.45}", type, builder.Build());
+        }
+
+        [Fact]
+        public void Decimal128_WidePrecision_AsString()
+        {
+            // Precision > 15 — values may not round-trip through double. Emit 
as a JSON
+            // string; consumers who care about precision parse it with a 
decimal type.
+            var type = new Decimal128Type(20, 2);
+            var builder = new Decimal128Array.Builder(type);
+            builder.Append(12345678901234567.89m);
+            AssertJson("{\"f\":\"12345678901234567.89\"}", type, 
builder.Build());
+        }
+
+        [Fact]
+        public void Decimal256_AsString()
+        {
+            // ValueAt returns Decimal256 values as strings (GetString), so 
the JSON is a string.
+            var type = new Decimal256Type(30, 2);
+            var builder = new Decimal256Array.Builder(type);
+            builder.Append(9999.99m);
+            AssertJson("{\"f\":\"9999.99\"}", type, builder.Build());
+        }
+
+        [Fact]
+        public void NullField()
+        {
+            // Build an Int32Array with a single null entry.
+            var builder = new Int32Array.Builder();
+            builder.AppendNull();
+            AssertJson("{\"f\":null}", Int32Type.Default, builder.Build());
+        }
+
+        [Fact]
+        public void ListOfInt32()
+        {
+            var listBuilder = new ListArray.Builder(Int32Type.Default);
+            var valuesBuilder = (Int32Array.Builder)listBuilder.ValueBuilder;
+            listBuilder.Append();
+            valuesBuilder.AppendRange(new[] { 1, 2, 3 });
+            ListArray list = listBuilder.Build();
+
+            var s = SingleFieldStruct("f", new ListType(Int32Type.Default), 
list);
+            Assert.Equal("{\"f\":[1,2,3]}", SerializeRow(s));
+        }
+
+        [Fact]
+        public void NestedStruct()
+        {
+            // outer struct containing an inner struct with two int fields
+            var innerA = new Int32Array.Builder().Append(10).Build();
+            var innerB = new StringArray.Builder().Append("ten").Build();
+
+            var innerType = new StructType(new[]
+            {
+                new Field("a", Int32Type.Default, nullable: true),
+                new Field("b", StringType.Default, nullable: true),
+            });
+
+            var innerStruct = new StructArray(
+                innerType, 1,
+                new IArrowArray[] { innerA, innerB },
+                new ArrowBuffer.BitmapBuilder().Build());
+
+            var outerType = new StructType(new[] { new Field("inner", 
innerType, nullable: true) });
+            var outer = new StructArray(
+                outerType, 1,
+                new IArrowArray[] { innerStruct },
+                new ArrowBuffer.BitmapBuilder().Build());
+
+            string actual = SerializeRow(outer);
+            Assert.True("{\"inner\":{\"a\":10,\"b\":\"ten\"}}" == actual, 
$"actual: {actual}");
+        }
+
+        private static void AssertJson(string expected, IArrowType type, 
IArrowArray values)
+        {
+            var s = SingleFieldStruct("f", type, values);
+            string actual = SerializeRow(s);
+            // Untruncated failure output so golden mismatches are actionable.
+            Assert.True(expected == actual, $"\nexpected: {expected}\nactual:  
 {actual}");
+        }
+    }
+}
diff --git 
a/csharp/test/Telemetry/Traces/Listeners/FileListener/OtelAttributesConverterTests.cs
 
b/csharp/test/Telemetry/Traces/Listeners/FileListener/OtelAttributesConverterTests.cs
new file mode 100644
index 000000000..9ba0434b2
--- /dev/null
+++ 
b/csharp/test/Telemetry/Traces/Listeners/FileListener/OtelAttributesConverterTests.cs
@@ -0,0 +1,158 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Text;
+using System.Text.Json;
+using Apache.Arrow.Adbc.Telemetry.Traces.Listeners.FileListener;
+
+namespace Apache.Arrow.Adbc.Tests.Telemetry.Traces.Listeners.FileListener
+{
+    /// <summary>
+    /// Verifies that <see cref="OtelAttributeWriter"/> emits OTel-compatible 
types as native
+    /// JSON scalars/arrays and falls back to an invariant-culture string for 
everything else.
+    /// </summary>
+    public class OtelAttributesConverterTests
+    {
+        private static string WriteDict(IReadOnlyDictionary<string, object?> 
dict)
+        {
+            var converter = new OtelAttributesDictionaryConverter();
+            using var ms = new MemoryStream();
+            using (var writer = new Utf8JsonWriter(ms))
+            {
+                converter.Write(writer, dict, new JsonSerializerOptions());
+            }
+            return Encoding.UTF8.GetString(ms.ToArray());
+        }
+
+        private static string WriteList(IReadOnlyList<KeyValuePair<string, 
object?>> list)
+        {
+            var converter = new OtelAttributesListConverter();
+            using var ms = new MemoryStream();
+            using (var writer = new Utf8JsonWriter(ms))
+            {
+                converter.Write(writer, list, new JsonSerializerOptions());
+            }
+            return Encoding.UTF8.GetString(ms.ToArray());
+        }
+
+        [Fact]
+        public void StringValueIsNativeString()
+            => Assert.Equal("{\"k\":\"v\"}", WriteDict(new Dictionary<string, 
object?> { ["k"] = "v" }));
+
+        [Fact]
+        public void BoolValueIsNativeBoolean()
+            => Assert.Equal("{\"k\":true}", WriteDict(new Dictionary<string, 
object?> { ["k"] = true }));
+
+        [Fact]
+        public void Int32ValueIsNativeNumber()
+            => Assert.Equal("{\"k\":42}", WriteDict(new Dictionary<string, 
object?> { ["k"] = 42 }));
+
+        [Fact]
+        public void Int64ValueIsNativeNumber()
+            => Assert.Equal("{\"k\":5000000000}", WriteDict(new 
Dictionary<string, object?> { ["k"] = 5_000_000_000L }));
+
+        [Fact]
+        public void DoubleValueIsNativeNumber()
+            => Assert.Equal("{\"k\":1.5}", WriteDict(new Dictionary<string, 
object?> { ["k"] = 1.5 }));
+
+        [Fact]
+        public void NullValueIsJsonNull()
+            => Assert.Equal("{\"k\":null}", WriteDict(new Dictionary<string, 
object?> { ["k"] = null }));
+
+        [Fact]
+        public void StringArrayIsJsonArrayOfStrings()
+            => Assert.Equal("{\"k\":[\"a\",\"b\"]}", WriteDict(new 
Dictionary<string, object?> { ["k"] = new[] { "a", "b" } }));
+
+        [Fact]
+        public void BoolArrayIsJsonArrayOfBooleans()
+            => Assert.Equal("{\"k\":[true,false]}", WriteDict(new 
Dictionary<string, object?> { ["k"] = new[] { true, false } }));
+
+        [Fact]
+        public void Int64ArrayIsJsonArrayOfNumbers()
+            => Assert.Equal("{\"k\":[1,2,3]}", WriteDict(new 
Dictionary<string, object?> { ["k"] = new long[] { 1, 2, 3 } }));
+
+        [Fact]
+        public void DoubleArrayIsJsonArrayOfNumbers()
+            => Assert.Equal("{\"k\":[1.5,2.5]}", WriteDict(new 
Dictionary<string, object?> { ["k"] = new[] { 1.5, 2.5 } }));
+
+        [Fact]
+        public void DateTimeFallsBackToInvariantString()
+        {
+            // Explicitly run under a comma-as-decimal locale to prove the 
output is invariant.
+            var prev = CultureInfo.CurrentCulture;
+            try
+            {
+                CultureInfo.CurrentCulture = new CultureInfo("de-DE");
+                var dt = new DateTime(2026, 4, 18, 12, 34, 56, 
DateTimeKind.Utc);
+                string actual = WriteDict(new Dictionary<string, object?> { 
["k"] = dt });
+                // IFormattable.ToString(null, invariant) for DateTime uses 
the invariant
+                // general format: "04/18/2026 12:34:56".
+                Assert.Equal("{\"k\":\"04/18/2026 12:34:56\"}", actual);
+            }
+            finally
+            {
+                CultureInfo.CurrentCulture = prev;
+            }
+        }
+
+        [Fact]
+        public void DecimalFallsBackToInvariantNumericString()
+        {
+            var prev = CultureInfo.CurrentCulture;
+            try
+            {
+                CultureInfo.CurrentCulture = new CultureInfo("de-DE");
+                // decimal isn't in the OTel spec — falls to invariant string. 
Period, not comma.
+                string actual = WriteDict(new Dictionary<string, object?> { 
["k"] = 1.5m });
+                Assert.Equal("{\"k\":\"1.5\"}", actual);
+            }
+            finally
+            {
+                CultureInfo.CurrentCulture = prev;
+            }
+        }
+
+        [Fact]
+        public void ListConverterWritesArrayOfKeyValueObjects()
+        {
+            var list = new[]
+            {
+                new KeyValuePair<string, object?>("a", 1),
+                new KeyValuePair<string, object?>("b", "two"),
+                new KeyValuePair<string, object?>("c", true),
+            };
+            
Assert.Equal("[{\"Key\":\"a\",\"Value\":1},{\"Key\":\"b\",\"Value\":\"two\"},{\"Key\":\"c\",\"Value\":true}]",
 WriteList(list));
+        }
+
+        [Fact]
+        public void ReadThrows()
+        {
+            var json = "{\"k\":1}";
+            byte[] bytes = Encoding.UTF8.GetBytes(json);
+            var options = new JsonSerializerOptions();
+            Assert.Throws<NotSupportedException>(() =>
+            {
+                var reader = new Utf8JsonReader(bytes);
+                new OtelAttributesDictionaryConverter().Read(ref reader, 
typeof(IReadOnlyDictionary<string, object?>), options);
+            });
+        }
+    }
+}

Reply via email to