diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..896a6fb
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,9 @@
+###############################################################################
+# Set default behavior to automatically normalize line endings.
+###############################################################################
+* text=auto
+
+# Set line endings to LF, even on Windows. Otherwise, execution within Docker fails.
+# See https://help.github.com/articles/dealing-with-line-endings/
+*.sh text eol=lf
+*.bash text eol=lf
diff --git a/BUILD.md b/BUILD.md
new file mode 100644
index 0000000..c3e05cf
--- /dev/null
+++ b/BUILD.md
@@ -0,0 +1,136 @@
+# Building and Testing
+
+This document describes how to build and test the DogStatsD C# client library.
+
+## Prerequisites
+
+- [.NET SDK 10.0 or above](https://dotnet.microsoft.com/download)
+
+## Quick Start
+
+```bash
+# Build the .NET library
+dotnet build
+
+# Run tests for a specific framework
+dotnet test --framework net8.0
+
+# Pack the NuGet package
+dotnet pack src/StatsdClient/StatsdClient.csproj -c Release
+# Output: artifacts/package/release/*.nupkg
+```
+
+## Building
+
+Build the main .NET library:
+
+```bash
+# Restore dependencies
+dotnet restore
+
+# Build all projects
+dotnet build
+
+# Build specific project
+dotnet build src/StatsdClient/StatsdClient.csproj
+
+# Build for specific configuration
+dotnet build -c Release
+
+# Build for specific target framework
+dotnet build src/StatsdClient/StatsdClient.csproj -f netstandard2.0
+```
+
+## Testing
+
+### Important: Framework Selection
+
+**Always specify `--framework` when running tests.** Running tests without a framework specification will run all target frameworks in parallel, causing conflicts due to shared named pipes.
+
+### Using dotnet test
+
+```bash
+# Run all tests for a specific framework
+dotnet test --framework net8.0
+
+# Run tests for specific project and framework
+dotnet test tests/StatsdClient.Tests/ --framework net8.0
+
+# Run specific test class
+dotnet test --framework net8.0 --filter FullyQualifiedName~DogStatsdServiceMetricsTests
+
+# Run specific test method
+dotnet test --framework net8.0 --filter FullyQualifiedName~DogStatsdServiceMetricsTests.Counter
+```
+
+### Testing All Frameworks Sequentially
+
+**Linux/macOS:**
+```bash
+for tfm in netcoreapp2.1 netcoreapp3.0 netcoreapp3.1 net5.0 net6.0 net7.0 net8.0 net9.0 net10.0; do
+ dotnet test --framework $tfm
+done
+```
+
+**Windows (includes .NET Framework):**
+```bash
+for tfm in net48 netcoreapp2.1 netcoreapp3.0 netcoreapp3.1 net5.0 net6.0 net7.0 net8.0 net9.0 net10.0; do
+ dotnet test --framework $tfm
+done
+```
+
+### Supported Test Frameworks
+
+The test project runs against:
+
+- .NET Framework 4.8 (Windows only)
+- .NET Core 2.1, 3.0, 3.1
+- .NET 5, 6, 7, 8, 9, 10
+
+The library itself (`src/StatsdClient/StatsdClient.csproj`) targets `net461`, `netstandard2.0`, `netcoreapp3.1`, and `net6.0`.
+
+## Packaging
+
+To create a NuGet package:
+
+```bash
+dotnet pack src/StatsdClient/StatsdClient.csproj -c Release
+
+# Output: artifacts/package/release/*.nupkg
+```
+
+The build uses .NET's artifacts output layout (`UseArtifactsOutput` in `Directory.Build.props`), so build and package outputs go to the top-level `artifacts/` directory rather than per-project `bin/` and `obj/` folders.
+
+## Benchmarks
+
+Run performance benchmarks:
+
+```bash
+dotnet run -c Release --project benchmarks/StatsdClient.Benchmarks/StatsdClient.Benchmarks.csproj
+```
+
+## Troubleshooting
+
+### Tests fail with "address already in use" or named pipe conflicts
+
+Make sure you're specifying `--framework` when running tests. Running multiple frameworks in parallel causes port and named pipe conflicts.
+
+```bash
+# ❌ Wrong - runs all frameworks in parallel
+dotnet test
+
+# ✅ Correct - runs single framework
+dotnet test --framework net8.0
+```
+
+## Clean Build
+
+Remove build artifacts:
+
+```bash
+# Clean .NET build outputs
+dotnet clean
+
+# Remove all build and package outputs
+rm -rf artifacts/
+```
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..1ab90b4
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,27 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Repository Overview
+
+This is the DogStatsD C# client library (https://github.com/DataDog/dogstatsd-csharp-client), a C# implementation of the DogStatsD protocol for sending metrics, events, and service checks to Datadog.
+
+## Building and Testing
+
+See [BUILD.md](BUILD.md) for build, test, packaging, and benchmark commands, and the list of target frameworks.
+
+Gotcha: always pass `--framework` to `dotnet test` (e.g. `dotnet test --framework net8.0`). Running all target frameworks in parallel causes named-pipe conflicts.
+
+## Architecture
+
+Metrics flow through: submission API -> router -> client-side aggregation or buffer -> background worker -> transport, with telemetry tracked throughout.
+
+- **Public API**: `DogStatsdService` is the thread-safe, instance-based entry point; it must be `Configure()`d before use and disposed to flush. The static `DogStatsd` class wraps a single global instance over the same implementation.
+- **Routing and aggregation**: stats are routed to client-side aggregators (Count, Gauge, Set) or sent directly to the buffer (Histogram, Distribution, Timing). Aggregation flushes on an interval (default 2s, configurable; set `StatsdConfig.ClientSideAggregation` to null to disable).
+- **Buffering and transport**: metrics are batched into datagrams up to a max packet size, then sent over UDP, a Unix domain socket, or a Windows named pipe. Submission is non-blocking; only `Flush()` and `Dispose()` block.
+- **Configuration**: `StatsdConfig` holds the server name/port and aggregation settings and reads environment variables such as `DD_AGENT_HOST`, `DD_DOGSTATSD_PORT`, `DD_ENTITY_ID`, `DD_SERVICE`, `DD_ENV`, and `DD_VERSION`.
+
+## Design Notes
+
+- Both `DogStatsdService` and the static `DogStatsd` are thread-safe. Custom worker handlers must be thread-safe when `workerThreadCount > 1`.
+- Hot paths use object pooling and struct-based stats to minimize allocations; keep this in mind before adding heap allocations to the submission path.
diff --git a/src/StatsdClient/IFileSystem.cs b/src/StatsdClient/IFileSystem.cs
index bde4519..8a1676d 100644
--- a/src/StatsdClient/IFileSystem.cs
+++ b/src/StatsdClient/IFileSystem.cs
@@ -1,6 +1,4 @@
-using System;
using System.IO;
-using Mono.Unix.Native;
namespace StatsdClient
{
@@ -81,15 +79,14 @@ public TextReader OpenText(string path)
/// True if the file stat was successful, false otherwise
public bool TryStat(string path, out ulong inode)
{
- if (Environment.OSVersion.Platform == PlatformID.Unix &&
- Syscall.stat(path, out var stat) > 0)
- {
- inode = stat.st_ino;
- return true;
- }
-
+#if !NETFRAMEWORK
+ // Inode lookup is only supported on Linux; TryGetInode performs the platform check.
+ return NativeMethods.TryGetInode(path, out inode);
+#else
+ // Inode lookup is unsupported on .NET Framework, which always runs on Windows.
inode = 0;
return false;
+#endif
}
}
}
diff --git a/src/StatsdClient/NativeMethods.cs b/src/StatsdClient/NativeMethods.cs
new file mode 100644
index 0000000..cdb7ffa
--- /dev/null
+++ b/src/StatsdClient/NativeMethods.cs
@@ -0,0 +1,90 @@
+#if !NETFRAMEWORK
+
+using System;
+using System.Runtime.InteropServices;
+
+namespace StatsdClient
+{
+ ///
+ /// P/Invoke wrapper for the libc statx system call to retrieve file inodes on Linux.
+ ///
+ ///
+ /// statx is used instead of stat because its result structure (struct statx)
+ /// has a fixed, architecture-independent layout defined by the kernel, so a single managed struct
+ /// definition works across all architectures and libc implementations (glibc and musl). The older
+ /// stat call has a layout that varies by architecture and libc, which is not safe to marshal directly.
+ ///
+ internal static class NativeMethods
+ {
+ // Resolve the path relative to the current working directory (used when the path is not a dirfd-relative path).
+ private const int AT_FDCWD = -100;
+
+ // Request only the inode number (stx_ino) from statx.
+ private const uint STATX_INO = 0x00000100;
+
+ ///
+ /// Gets a value indicating whether the inode lookup is supported on the current platform (Linux only).
+ ///
+ public static bool IsSupported => RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
+
+ ///
+ /// Attempts to get the inode number for the specified file path.
+ ///
+ /// The file path.
+ /// The inode number if successful, 0 otherwise.
+ /// true if the inode was successfully retrieved, false otherwise.
+ public static bool TryGetInode(string path, out ulong inode)
+ {
+ inode = 0;
+
+ if (string.IsNullOrEmpty(path))
+ {
+ return false;
+ }
+
+ if (!IsSupported)
+ {
+ return false;
+ }
+ try
+ {
+ // flags = 0 means symlinks are followed, matching stat() behavior (needed for the
+ // /proc/self/ns/cgroup magic link). statx returns -1 on failure, including ENOSYS on
+ // kernels older than 4.11, in which case we fall back to no inode.
+ if (Statx(AT_FDCWD, path, 0, STATX_INO, out var buffer) != 0)
+ {
+ return false;
+ }
+
+ inode = buffer.StatxIno;
+ return true;
+ }
+ catch (DllNotFoundException)
+ {
+ // libc not available
+ return false;
+ }
+ catch (EntryPointNotFoundException)
+ {
+ // statx not available (e.g. glibc older than 2.28)
+ return false;
+ }
+ }
+
+ [DllImport("libc", EntryPoint = "statx", SetLastError = true, CharSet = CharSet.Ansi)]
+ private static extern int Statx(int dirfd, string pathname, int flags, uint mask, out StatxBuffer buffer);
+
+ ///
+ /// Mirror of the kernel's struct statx (256 bytes). Only the inode field is read; the
+ /// full size is preserved so the kernel does not write past the buffer.
+ ///
+ [StructLayout(LayoutKind.Explicit, Size = 256)]
+ private struct StatxBuffer
+ {
+ // stx_ino lives at byte offset 32 in struct statx.
+ [FieldOffset(32)]
+ public ulong StatxIno;
+ }
+ }
+}
+#endif
diff --git a/src/StatsdClient/StatsdClient.csproj b/src/StatsdClient/StatsdClient.csproj
index c013ff1..4881283 100644
--- a/src/StatsdClient/StatsdClient.csproj
+++ b/src/StatsdClient/StatsdClient.csproj
@@ -38,7 +38,6 @@
-
diff --git a/src/StatsdClient/UnixEndPoint.cs b/src/StatsdClient/UnixEndPoint.cs
new file mode 100644
index 0000000..3ee6b52
--- /dev/null
+++ b/src/StatsdClient/UnixEndPoint.cs
@@ -0,0 +1,152 @@
+// From https://raw.githubusercontent.com/mono/mono/master/mcs/class/Mono.Posix/Mono.Unix/UnixEndPoint.cs
+
+//
+// Mono.Unix.UnixEndPoint: EndPoint derived class for AF_UNIX family sockets.
+//
+// Authors:
+// Gonzalo Paniagua Javier (gonzalo@ximian.com)
+//
+// (C) 2003 Ximian, Inc (http://www.ximian.com)
+//
+
+//
+// Permission is hereby granted, free of charge, to any person obtaining
+// a copy of this software and associated documentation files (the
+// "Software"), to deal in the Software without restriction, including
+// without limitation the rights to use, copy, modify, merge, publish,
+// distribute, sublicense, and/or sell copies of the Software, and to
+// permit persons to whom the Software is furnished to do so, subject to
+// the following conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+//
+using System;
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+
+namespace Mono.Unix
+{
+ internal class UnixEndPoint : EndPoint
+ {
+ private string filename;
+
+ public UnixEndPoint(string filename)
+ {
+ if (filename == null)
+ {
+ throw new ArgumentNullException("filename");
+ }
+
+ if (filename == string.Empty)
+ {
+ throw new ArgumentException("Cannot be empty.", "filename");
+ }
+
+ this.filename = filename;
+ }
+
+ public string Filename
+ {
+ get
+ {
+ return (filename);
+ }
+
+ set
+ {
+ filename = value;
+ }
+ }
+
+ public override AddressFamily AddressFamily
+ {
+ get { return AddressFamily.Unix; }
+ }
+
+ public override EndPoint Create(SocketAddress socketAddress)
+ {
+ /*
+ * Should also check this
+ *
+ int addr = (int) AddressFamily.Unix;
+ if (socketAddress [0] != (addr & 0xFF))
+ throw new ArgumentException ("socketAddress is not a unix socket address.");
+
+ if (socketAddress [1] != ((addr & 0xFF00) >> 8))
+ throw new ArgumentException ("socketAddress is not a unix socket address.");
+ */
+
+ if (socketAddress.Size == 2)
+ {
+ // Empty filename.
+ // Probably from RemoteEndPoint which on linux does not return the file name.
+ UnixEndPoint uep = new UnixEndPoint("a");
+ uep.filename = string.Empty;
+ return uep;
+ }
+
+ int size = socketAddress.Size - 2;
+ byte[] bytes = new byte[size];
+ for (int i = 0; i < bytes.Length; i++)
+ {
+ bytes[i] = socketAddress[i + 2];
+ // There may be junk after the null terminator, so ignore it all.
+ if (bytes[i] == 0)
+ {
+ size = i;
+ break;
+ }
+ }
+
+ string name = Encoding.UTF8.GetString(bytes, 0, size);
+ return new UnixEndPoint(name);
+ }
+
+ public override SocketAddress Serialize()
+ {
+ byte[] bytes = Encoding.UTF8.GetBytes(filename);
+ SocketAddress sa = new SocketAddress(AddressFamily, 2 + bytes.Length + 1);
+ // sa [0] -> family low byte, sa [1] -> family high byte
+ for (int i = 0; i < bytes.Length; i++)
+ {
+ sa[2 + i] = bytes[i];
+ }
+
+ // NULL suffix for non-abstract path
+ sa[2 + bytes.Length] = 0;
+
+ return sa;
+ }
+
+ public override string ToString()
+ {
+ return (filename);
+ }
+
+ public override int GetHashCode()
+ {
+ return filename.GetHashCode();
+ }
+
+ public override bool Equals(object o)
+ {
+ UnixEndPoint other = o as UnixEndPoint;
+ if (other == null)
+ {
+ return false;
+ }
+
+ return (other.filename == filename);
+ }
+ }
+}
diff --git a/tests/StatsdClient.Tests/NativeLibraryTests.cs b/tests/StatsdClient.Tests/NativeLibraryTests.cs
new file mode 100644
index 0000000..f7a9d13
--- /dev/null
+++ b/tests/StatsdClient.Tests/NativeLibraryTests.cs
@@ -0,0 +1,233 @@
+#if !NETFRAMEWORK
+
+using System;
+using System.IO;
+using System.Runtime.InteropServices;
+using NUnit.Framework;
+using StatsdClient;
+
+namespace Tests
+{
+ ///
+ /// Integration tests for the inode lookup (statx P/Invoke) on Linux.
+ /// These tests exercise the real system call rather than mocking.
+ ///
+ [TestFixture]
+ public class NativeLibraryTests
+ {
+ private FileSystem _fileSystem;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _fileSystem = new FileSystem();
+ }
+
+ [Test]
+ public void TryStat_WithValidFile_ReturnsTrue()
+ {
+ // Only run on Linux where statx is available
+ if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+ {
+ Assert.Ignore("Test only runs on Linux");
+ }
+
+ // Use a file that should always exist on Linux
+ var testPath = "/proc/self/exe";
+
+ var result = _fileSystem.TryStat(testPath, out ulong inode);
+
+ Assert.IsTrue(result, "TryStat should succeed for valid file");
+ Assert.Greater(inode, 0UL, "Inode should be greater than 0");
+ }
+
+ [Test]
+ public void TryStat_WithNonExistentFile_ReturnsFalse()
+ {
+ if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+ {
+ Assert.Ignore("Test only runs on Linux");
+ }
+
+ var testPath = "/this/path/does/not/exist/file.txt";
+
+ var result = _fileSystem.TryStat(testPath, out ulong inode);
+
+ Assert.IsFalse(result, "TryStat should fail for non-existent file");
+ Assert.AreEqual(0UL, inode, "Inode should be 0 on failure");
+ }
+
+ [Test]
+ public void TryStat_WithSameFileTwice_ReturnsSameInode()
+ {
+ if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+ {
+ Assert.Ignore("Test only runs on Linux");
+ }
+
+ var testPath = "/proc/self/exe";
+
+ var result1 = _fileSystem.TryStat(testPath, out ulong inode1);
+ var result2 = _fileSystem.TryStat(testPath, out ulong inode2);
+
+ Assert.IsTrue(result1, "First TryStat should succeed");
+ Assert.IsTrue(result2, "Second TryStat should succeed");
+ Assert.AreEqual(inode1, inode2, "Same file should have same inode");
+ }
+
+ [Test]
+ public void TryStat_WithDifferentFiles_ReturnsDifferentInodes()
+ {
+ if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+ {
+ Assert.Ignore("Test only runs on Linux");
+ }
+
+ var path1 = "/proc/self/exe";
+ var path2 = "/proc/self/cmdline";
+
+ var result1 = _fileSystem.TryStat(path1, out ulong inode1);
+ var result2 = _fileSystem.TryStat(path2, out ulong inode2);
+
+ Assert.IsTrue(result1, "First TryStat should succeed");
+ Assert.IsTrue(result2, "Second TryStat should succeed");
+ Assert.AreNotEqual(inode1, inode2, "Different files should have different inodes");
+ }
+
+ [Test]
+ public void TryStat_WithTemporaryFile_ReturnsValidInode()
+ {
+ if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+ {
+ Assert.Ignore("Test only runs on Linux");
+ }
+
+ // Create a temporary file
+ var tempFile = Path.GetTempFileName();
+
+ try
+ {
+ File.WriteAllText(tempFile, "test content");
+
+ var result = _fileSystem.TryStat(tempFile, out ulong inode);
+
+ Assert.IsTrue(result, "TryStat should succeed for temp file");
+ Assert.Greater(inode, 0UL, "Inode should be greater than 0");
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ {
+ File.Delete(tempFile);
+ }
+ }
+ }
+
+ [Test]
+ public void TryStat_WithDirectory_ReturnsValidInode()
+ {
+ if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+ {
+ Assert.Ignore("Test only runs on Linux");
+ }
+
+ var testPath = "/proc/self";
+
+ var result = _fileSystem.TryStat(testPath, out ulong inode);
+
+ Assert.IsTrue(result, "TryStat should succeed for directory");
+ Assert.Greater(inode, 0UL, "Inode should be greater than 0 for directory");
+ }
+
+ [Test]
+ public void TryStat_OnNonLinux_ReturnsFalse()
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+ {
+ Assert.Ignore("Test only runs on non-Linux platforms");
+ }
+
+ var testPath = "/some/path";
+
+ var result = _fileSystem.TryStat(testPath, out ulong inode);
+
+ Assert.IsFalse(result, "TryStat should return false on non-Linux platforms");
+ Assert.AreEqual(0UL, inode, "Inode should be 0 on non-Linux platforms");
+ }
+
+ [Test]
+ public void TryStat_VerifyHostCgroupNamespaceInode()
+ {
+ if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+ {
+ Assert.Ignore("Test only runs on Linux");
+ }
+
+ var cgroupNsPath = "/proc/self/ns/cgroup";
+
+ // This test verifies we can read the cgroup namespace inode
+ // It will be 0xEFFFFFFB (4026531835) if running in the host namespace
+ var result = _fileSystem.TryStat(cgroupNsPath, out ulong inode);
+
+ Assert.IsTrue(result, "TryStat should succeed for cgroup namespace");
+ Assert.Greater(inode, 0UL, "Inode should be greater than 0");
+
+ // Note: We don't assert the specific value because it depends on whether
+ // we're running in a container or on the host
+ Console.WriteLine($"Cgroup namespace inode: {inode} (0x{inode:X})");
+ }
+
+ [Test]
+ public void TryStat_WithSymlink_ReturnsTargetInode()
+ {
+ if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+ {
+ Assert.Ignore("Test only runs on Linux");
+ }
+
+ // /proc/self/exe is typically a symlink to the actual executable
+ var symlinkPath = "/proc/self/exe";
+
+ var result = _fileSystem.TryStat(symlinkPath, out ulong inode);
+
+ Assert.IsTrue(result, "TryStat should succeed for symlink");
+ Assert.Greater(inode, 0UL, "Inode should be greater than 0");
+
+ // statx with flags = 0 follows symlinks, so we should get the target's inode
+ // We can't easily verify this without lstat support, but at least verify it works
+ }
+
+ [Test]
+ public void NativeMethods_DirectCall_WithValidFile()
+ {
+ if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+ {
+ Assert.Ignore("Test only runs on Linux");
+ }
+
+ var testPath = "/proc/self/exe";
+
+ // Test the NativeMethods wrapper directly
+ var result = NativeMethods.TryGetInode(testPath, out ulong inode);
+
+ Assert.IsTrue(result, "NativeMethods.TryGetInode should succeed");
+ Assert.Greater(inode, 0UL, "Inode should be greater than 0");
+ }
+
+ [Test]
+ public void NativeMethods_IsSupported_ReflectsPlatform()
+ {
+ var isSupported = NativeMethods.IsSupported;
+
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+ {
+ Assert.IsTrue(isSupported, "IsSupported should be true on Linux");
+ }
+ else
+ {
+ Assert.IsFalse(isSupported, "IsSupported should be false on non-Linux");
+ }
+ }
+ }
+}
+#endif