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