From 75bd3e4be31d823e41259c7a994acd9cc16e11ef Mon Sep 17 00:00:00 2001 From: Sergio Barrio Date: Tue, 17 Feb 2026 16:42:17 +0100 Subject: [PATCH] [RUM-14555] DdSdk Core Configuration Implement DdSdkConfiguration class with full SDK initialization parameters, pass them through the native bridge to iOS and Android SDKs. Co-Authored-By: Claude Opus 4.6 (1M context) --- AGENTS.md | 157 +++++++-- CONTRIBUTING.md | 38 ++ README.md | 70 +++- SPEC.md | 331 ++++++++++++------ .../Configuration/BatchProcessingLevel.cs | 9 + .../Configuration/BatchSize.cs | 9 + .../Configuration/DatadogSite.cs | 13 + .../Configuration/DdSdkConfiguration.cs | 23 ++ .../Configuration/FileBasedConfiguration.cs | 124 +++++++ .../Configuration/TrackingConsent.cs | 9 + .../Configuration/UploadFrequency.cs | 9 + bindings/DatadogSdk.Maui/DdSdk.cs | 160 ++++++++- .../DatadogSdk.Maui/DdSdkConfiguration.cs | 11 - .../ApiDefinition.Core.cs | 15 +- build.sh | 7 +- check.sh | 181 ++++++++++ example.slnx | 1 + example/MauiProgram.cs | 12 +- example/Platforms/Android/AndroidManifest.xml | 2 +- .../Resources/xml/network_security_config.xml | 8 + .../Resources/Raw/appsettings.json.example | 6 +- example/build.sh | 10 +- .../android/datadogwrapper/build.gradle.kts | 4 + .../com/datadog/wrapper/DatadogWrapper.kt | 117 +++++-- .../com/datadog/wrapper/DatadogWrapperTest.kt | 269 ++++++++++++++ .../ios/DatadogWrapper/Package.swift | 9 +- .../DatadogWrapper/DatadogWrapper.swift | 134 +++++-- .../Sources/DatadogWrapper/DdLogs.swift | 8 +- .../DatadogWrapperTests.swift | 199 +++++++++++ .../DatadogSdk.Maui.Tests.csproj | 33 ++ .../DdSdkConfigurationTests.cs | 268 ++++++++++++++ .../DdSdkConversionTests.cs | 94 +++++ .../FileBasedConfigurationTests.cs | 144 ++++++++ .../Fixtures/full_config.json | 17 + .../Fixtures/malformed_config.json | 1 + .../Fixtures/minimal_config.json | 4 + .../DatadogSdk.Maui.Tests/InternalLogTests.cs | 65 ++++ .../MockNativeSdkBridge.cs | 37 ++ 38 files changed, 2347 insertions(+), 261 deletions(-) create mode 100644 bindings/DatadogSdk.Maui/Configuration/BatchProcessingLevel.cs create mode 100644 bindings/DatadogSdk.Maui/Configuration/BatchSize.cs create mode 100644 bindings/DatadogSdk.Maui/Configuration/DatadogSite.cs create mode 100644 bindings/DatadogSdk.Maui/Configuration/DdSdkConfiguration.cs create mode 100644 bindings/DatadogSdk.Maui/Configuration/FileBasedConfiguration.cs create mode 100644 bindings/DatadogSdk.Maui/Configuration/TrackingConsent.cs create mode 100644 bindings/DatadogSdk.Maui/Configuration/UploadFrequency.cs delete mode 100644 bindings/DatadogSdk.Maui/DdSdkConfiguration.cs create mode 100755 check.sh create mode 100644 example/Platforms/Android/Resources/xml/network_security_config.xml create mode 100644 native-wrappers/android/datadogwrapper/src/test/kotlin/com/datadog/wrapper/DatadogWrapperTest.kt create mode 100644 native-wrappers/ios/DatadogWrapper/Tests/DatadogWrapperTests/DatadogWrapperTests.swift create mode 100644 tests/DatadogSdk.Maui.Tests/DatadogSdk.Maui.Tests.csproj create mode 100644 tests/DatadogSdk.Maui.Tests/DdSdkConfigurationTests.cs create mode 100644 tests/DatadogSdk.Maui.Tests/DdSdkConversionTests.cs create mode 100644 tests/DatadogSdk.Maui.Tests/FileBasedConfigurationTests.cs create mode 100644 tests/DatadogSdk.Maui.Tests/Fixtures/full_config.json create mode 100644 tests/DatadogSdk.Maui.Tests/Fixtures/malformed_config.json create mode 100644 tests/DatadogSdk.Maui.Tests/Fixtures/minimal_config.json create mode 100644 tests/DatadogSdk.Maui.Tests/InternalLogTests.cs create mode 100644 tests/DatadogSdk.Maui.Tests/MockNativeSdkBridge.cs diff --git a/AGENTS.md b/AGENTS.md index 50fedc4..ba5780b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,10 +6,13 @@ This document provides context and instructions for AI agents working on the Dat **Purpose**: Provide .NET MAUI bindings for Datadog's native iOS and Android SDKs, enabling observability features (logs, RUM, traces) in cross-platform mobile applications. -**Current Status**: Phase 1 Complete (Foundation & Bindings + Logs) +**Current Status**: Phase 2 Complete (Core SDK Configuration) - ✅ iOS native wrapper with XCFramework bindings - ✅ Android native wrapper with multi-project NuGet bindings - ✅ Unified meta-package (DatadogSdk.Maui) +- ✅ Full `DdSdkConfiguration` object (TrackingConsent, BatchSize, BatchProcessingLevel, UploadFrequency, Site, Service, Version/VersionSuffix, Verbosity, AdditionalConfiguration) +- ✅ `_dd.needsClearTextHttp` internal key support via `AdditionalConfiguration` +- ✅ Unit tests at all three layers (`check.sh`) - ✅ Example app validated on both platforms - ✅ Logs successfully reaching Datadog backend @@ -64,6 +67,7 @@ Consumer App (.NET MAUI) ``` dd-sdk-maui/ ├── build.sh # Root build script (rebuilds everything) +├── check.sh # Runs all unit tests (iOS/Android/C#) ├── NuGet.Config # Local package source configuration │ ├── native-wrappers/ # Platform-native code @@ -72,22 +76,27 @@ dd-sdk-maui/ │ │ └── DatadogWrapper/ # Swift Package Manager project │ │ ├── Package.swift # SPM manifest (dd-sdk-ios dependency) │ │ └── Sources/DatadogWrapper/ -│ │ ├── DatadogWrapper.swift # SDK initialization -│ │ └── DdLogs.swift # Logging API +│ │ ├── DatadogWrapper.swift # SDK initialization (DdSdkNativeWrapper) +│ │ ├── DdLogs.swift # Logging API +│ │ └── Protocols/ # Dependency injection protocols │ │ │ └── android/ │ ├── build.gradle.kts # Root Gradle project │ ├── gradlew # Gradle wrapper │ └── datadogwrapper/ # Android library module │ ├── build.gradle.kts # Module config (dd-sdk-android deps) -│ └── src/main/kotlin/com/datadog/wrapper/ -│ ├── DatadogWrapper.kt # SDK initialization -│ └── DdLogs.kt # Logging API +│ └── src/ +│ ├── main/kotlin/com/datadog/wrapper/ +│ │ ├── DatadogWrapper.kt # SDK initialization +│ │ └── DdLogs.kt # Logging API +│ └── test/kotlin/com/datadog/wrapper/ +│ ├── DatadogWrapperTest.kt +│ └── DdLogsTest.kt │ ├── bindings/ # C# binding projects │ ├── DatadogSdk.iOS.Binding/ -│ │ ├── ApiDefinition.cs # iOS binding interface definitions -│ │ ├── StructsAndEnums.cs # Supporting types +│ │ ├── ApiDefinition.Core.cs # iOS core binding interface +│ │ ├── ApiDefinition.Logs.cs # iOS logs binding interface │ │ └── NativeReference/ │ │ └── DatadogWrapper.xcframework/ # XCFramework binary │ │ @@ -102,14 +111,42 @@ dd-sdk-maui/ │ │ └── proguard.txt # R8/ProGuard rules │ │ │ └── DatadogSdk.Maui/ # Meta-package (unified) -│ └── DatadogSdk.Maui.csproj # References iOS + Android bindings +│ ├── Configuration/ # Configuration namespace +│ │ ├── DdSdkConfiguration.cs +│ │ ├── FileBasedConfiguration.cs # JSON config parser +│ │ ├── TrackingConsent.cs +│ │ ├── BatchSize.cs +│ │ ├── BatchProcessingLevel.cs +│ │ ├── UploadFrequency.cs +│ │ ├── DatadogSite.cs +│ │ └── SdkVerbosity.cs +│ ├── DdSdk.cs # SDK initialization + BuildAdditionalConfiguration +│ ├── DdLogs.cs # Logging API +│ └── InternalLog.cs # SDK-internal console logging +│ +├── tests/ # C# unit tests +│ └── DatadogSdk.Maui.Tests/ +│ ├── DdSdkConfigurationTests.cs # SDK init via test bridge (INativeBridge) +│ ├── DdSdkConversionTests.cs # ConvertSite, ConvertTrackingConsent, +│ │ # BuildAdditionalConfiguration +│ ├── FileBasedConfigurationTests.cs # JSON config parsing + validation +│ ├── MockNativeSdkBridge.cs # Shared test double for DdSdk.INativeBridge +│ ├── InternalLogTests.cs +│ └── Fixtures/ # JSON test fixtures +│ ├── full_config.json +│ ├── minimal_config.json +│ └── malformed_config.json │ ├── example/ # Test/demo MAUI application │ ├── build.sh # Example app build script -│ ├── example.csproj # References DatadogSdk.Maui +│ ├── MauiProgram.cs # SDK initialization +│ ├── MainPage.xaml.cs # Log sending UI +│ ├── Resources/Raw/appsettings.json # ClientToken, Environment │ └── Platforms/ -│ ├── iOS/AppDelegate.cs # iOS initialization -│ └── Android/MainActivity.cs # Android initialization +│ └── Android/ +│ ├── AndroidManifest.xml # networkSecurityConfig reference +│ └── Resources/xml/ +│ └── network_security_config.xml # Allows cleartext HTTP │ └── local-packages/ # Local NuGet package output ├── DatadogSdk.iOS.Binding.1.0.0.nupkg @@ -211,35 +248,51 @@ cd example **Objective-C Export Requirements:** ```swift // Swift classes MUST be @objc and inherit from NSObject -@objc(DatadogWrapper) -public class DatadogWrapper: NSObject { +@objc(DatadogWrapper) // ObjC name stays DatadogWrapper for binding compatibility +public class DdSdkNativeWrapper: NSObject { + + // Mapping helpers (testable independently) + static func mapSite(_ site: String) -> DatadogSite { ... } + static func mapTrackingConsent(_ consent: String) -> TrackingConsent { ... } + static func mapVerbosity(_ verbosity: String) -> CoreLoggerLevel { ... } + static func mapBatchSize(_ batchSize: String) -> Datadog.Configuration.BatchSize { ... } + static func mapUploadFrequency(_ freq: String) -> Datadog.Configuration.UploadFrequency { ... } + static func mapBatchProcessingLevel(_ level: String) -> Datadog.Configuration.BatchProcessingLevel { ... } - // Static methods need @objc annotation @objc public static func initialize( clientToken: String, environment: String, - service: String - ) -> Bool { - // Implementation - } + service: String?, // nullable + site: String, + verbosity: String, + trackingConsent: String, + batchSize: String?, + uploadFrequency: String?, + batchProcessingLevel: String?, + additionalConfiguration: NSDictionary? + ) -> Bool { ... } } ``` -**C# Binding Syntax:** +**C# Binding Syntax** (`ApiDefinition.Core.cs`): ```csharp [BaseType(typeof(NSObject))] interface DatadogWrapper { [Static] - [Export("initializeWithClientToken:environment:service:")] - bool Initialize(string clientToken, string environment, string service); + [Export("initializeWithClientToken:environment:service:site:verbosity:trackingConsent:batchSize:uploadFrequency:batchProcessingLevel:additionalConfiguration:")] + bool Initialize(string clientToken, string environment, + [NullAllowed] string service, string site, string verbosity, + string trackingConsent, [NullAllowed] string batchSize, + [NullAllowed] string uploadFrequency, [NullAllowed] string batchProcessingLevel, + [NullAllowed] NSDictionary additionalConfiguration); } ``` **Export Selector Pattern:** -- Swift method: `initialize(clientToken:environment:service:)` -- Objective-C selector: `initializeWithClientToken:environment:service:` -- Parameter names become part of selector +- Swift method: `initialize(clientToken:environment:service:site:verbosity:...)` +- Objective-C selector: `initializeWithClientToken:environment:service:site:verbosity:...` +- Each parameter label becomes part of the selector after the first ### Android Binding Specifics @@ -247,20 +300,34 @@ interface DatadogWrapper ```kotlin class DatadogWrapper { companion object { + // Mapping helpers (testable independently) + @JvmStatic fun mapSite(site: String): DatadogSite = ... + @JvmStatic fun mapTrackingConsent(consent: String): TrackingConsent = ... + @JvmStatic fun mapVerbosity(verbosity: String): Int = ... // returns Log.* constant + @JvmStatic fun mapBatchSize(batchSize: String): BatchSize = ... + @JvmStatic fun mapUploadFrequency(uploadFrequency: String): UploadFrequency = ... + @JvmStatic fun mapBatchProcessingLevel(level: String): BatchProcessingLevel = ... + @JvmStatic // Critical: exposes as static method to C# fun initialize( context: Context, clientToken: String, environment: String, - service: String, - site: String = "us1" - ): Boolean { - // Implementation - } + service: String?, // nullable + site: String = "us1", + verbosity: String = "error", + trackingConsent: String = "pending", + batchSize: String? = null, + uploadFrequency: String? = null, + batchProcessingLevel: String? = null, + additionalConfiguration: Map? = null + ): Boolean { ... } } } ``` +**`_dd.needsClearTextHttp` support**: if `additionalConfiguration["_dd.needsClearTextHttp"] == true`, the wrapper calls `_InternalProxy.allowClearTextHttp(builder)` before building the configuration. Pair with a custom endpoint and `network_security_config.xml` allowing cleartext for local testing. + **Metadata.xml Transformations:** ```xml @@ -365,30 +432,38 @@ export ANDROID_HOME=$HOME/Library/Android/sdk Before committing changes: -1. **Build native wrappers** +1. **Run unit tests** + ```bash + ./check.sh # All suites + ./check.sh --maui # C# xUnit only + ./check.sh --android # Kotlin JUnit + MockK only + ./check.sh --ios # Swift XCTest only + ``` + +2. **Build native wrappers** ```bash cd native-wrappers/ios && ./build.sh cd ../android && ./gradlew :datadogwrapper:assembleRelease ``` -2. **Rebuild all bindings** +3. **Rebuild all bindings** ```bash cd ../.. && ./build.sh ``` -3. **Test iOS** +4. **Test iOS** ```bash cd example && ./build.sh --clean --ios --run # Verify logs in Datadog UI ``` -4. **Test Android** +5. **Test Android** ```bash ./build.sh --clean --android --run # Verify logs in Datadog UI ``` -5. **Check NuGet packages** +6. **Check NuGet packages** ```bash ls -lh ../local-packages/ # Verify timestamps are recent @@ -400,6 +475,9 @@ Before committing changes: # Full rebuild ./build.sh +# Run all unit tests +./check.sh + # iOS build and run cd example && ./build.sh --ios --run @@ -415,6 +493,15 @@ unzip -l bindings/DatadogSdk.Android.Binding/Jars/datadogwrapper-release.aar # View generated Android binding code ls bindings/DatadogSdk.Android.Binding/obj/Release/net10.0-android/generated/src/ +# Run C# unit tests directly +dotnet test tests/DatadogSdk.Maui.Tests/ + +# Run Android unit tests directly +cd native-wrappers/android && ./gradlew :datadogwrapper:test + +# Run iOS unit tests directly +cd native-wrappers/ios/DatadogWrapper && swift test + # Clean all build artifacts git clean -fdx -e local-packages -e .planning ``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e60e21..7692eb9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -373,6 +373,44 @@ You should see logs like: [ERROR] iOS binding validation - LogError works! ``` +### Unit Tests + +Run the C# unit tests to verify SDK behavior: + +```bash +# Run all tests +dotnet test tests/DatadogSdk.Maui.Tests + +# Run with verbose output +dotnet test tests/DatadogSdk.Maui.Tests -v detailed + +# Run specific test class +dotnet test tests/DatadogSdk.Maui.Tests --filter "FullyQualifiedName~DdSdkConfigurationTests" +``` + +**Test coverage:** +- `DdSdkConfigurationTests` - SDK initialization with all config options via test bridge +- `DdSdkConversionTests` - Enum-to-string conversions (site, consent, additional config) +- `FileBasedConfigurationTests` - JSON config parsing, validation, and error handling +- `InternalLogTests` - SDK logging with verbosity filtering + +### Native Wrapper Tests + +**iOS tests** (XCTest): +```bash +cd native-wrappers/ios/DatadogWrapper +swift test +``` + +**Android tests** (JUnit + MockK): +```bash +cd native-wrappers/android +./gradlew test + +# View test report +open datadogwrapper/build/reports/tests/testDebugUnitTest/index.html +``` + ### Debugging Build Issues **Enable verbose logging**: diff --git a/README.md b/README.md index 9005911..972ceeb 100644 --- a/README.md +++ b/README.md @@ -24,15 +24,60 @@ Initialize the SDK in your `MauiProgram.cs`: ```csharp using DatadogSdk.Maui; +using DatadogSdk.Maui.Configuration; DdSdk.Initialize(new DdSdkConfiguration { + // Required ClientToken = "your-client-token", Environment = "prod", - Service = "my-maui-app" + TrackingConsent = TrackingConsent.Granted, + + // Optional + Service = "my-maui-app", + Site = DatadogSite.Us1, + BatchSize = BatchSize.Medium, + UploadFrequency = UploadFrequency.Average }); ``` +#### File-based Configuration + +You can also initialize from a JSON configuration file: + +```json +{ + "ClientToken": "your-client-token", + "Environment": "prod", + "Site": "Us1", + "Service": "my-maui-app", + "TrackingConsent": "Granted", + "Verbosity": "DEBUG" +} +``` + +```csharp +string json = File.ReadAllText("appsettings.json"); +var config = FileBasedConfiguration.ParseJsonConfig(json); +DdSdk.Initialize(config); +``` + +**Required fields:** +- `ClientToken` - Your Datadog client token +- `Environment` - Environment name (e.g., "prod", "staging") +- `TrackingConsent` - User tracking consent (`Granted`, `NotGranted`, or `Pending`) + +**Optional fields:** +- `Service` - Service name +- `Site` - Datadog site (`Us1`, `Us3`, `Us5`, `Eu1`, `Ap1`, `Ap2`, `Us1Fed`) +- `BatchSize` - Batch size for uploads (`Small`, `Medium`, `Large`) +- `BatchProcessingLevel` - Processing level (`Low`, `Medium`, `High`) +- `UploadFrequency` - Upload frequency (`Frequent`, `Average`, `Rare`) +- `Version` - Application version +- `VersionSuffix` - Version suffix +- `Verbosity` - SDK logging level +- `AdditionalConfiguration` - Additional configuration dictionary + ### Logs ```csharp @@ -53,13 +98,34 @@ If you encounter issues while using the SDK, check the existing [GitHub Issues]( You can also enable verbose SDK logging to help diagnose issues: ```csharp +using DatadogSdk.Maui; +using DatadogSdk.Maui.Configuration; + DdSdk.Initialize(new DdSdkConfiguration { - // ... + ClientToken = "your-client-token", + Environment = "prod", + TrackingConsent = TrackingConsent.Granted, Verbosity = SdkVerbosity.DEBUG }); ``` +## Testing + +Run all test suites (iOS, Android, C#): + +```bash +./check.sh +``` + +Individual suites: + +```bash +./check.sh --maui # C# unit tests (xUnit) +./check.sh --ios # Swift tests (XCTest) +./check.sh --android # Kotlin tests (JUnit + MockK) +``` + ## Contributing Pull requests are welcome. First, open an issue to discuss what you would like to change. diff --git a/SPEC.md b/SPEC.md index 1a8f151..63200ff 100644 --- a/SPEC.md +++ b/SPEC.md @@ -4,9 +4,9 @@ This document provides a comprehensive technical specification for the Datadog SDK .NET MAUI bindings. It describes the architecture, implementation details, build process, and design decisions. -**Version**: 1.0.0 (Phase 1 Complete) +**Version**: 1.0.0 (Phase 2 Complete) **Target Frameworks**: net10.0-ios, net10.0-android -**Status**: Foundation complete, Logs module functional +**Status**: Core SDK configuration complete, Logs module functional ## Project Goals @@ -163,50 +163,45 @@ All Swift classes must: Example: ```swift @objc(DatadogWrapper) -public class DatadogWrapper: NSObject { +public class DdSdkNativeWrapper: NSObject { + + // Mapping helpers — extracted as testable pure functions + static func mapSite(_ site: String) -> DatadogSite { ... } + static func mapTrackingConsent(_ consent: String) -> TrackingConsent { ... } + static func mapVerbosity(_ verbosity: String) -> CoreLoggerLevel { ... } + static func mapBatchSize(_ batchSize: String) -> Datadog.Configuration.BatchSize { ... } + static func mapUploadFrequency(_ freq: String) -> Datadog.Configuration.UploadFrequency { ... } + static func mapBatchProcessingLevel(_ level: String) -> Datadog.Configuration.BatchProcessingLevel { ... } @objc public static func initialize( clientToken: String, environment: String, - service: String, + service: String?, // nullable — optional service name site: String, - verbosity: String + verbosity: String, + trackingConsent: String, // "granted", "not_granted", "pending" + batchSize: String?, // "small", "medium", "large" + uploadFrequency: String?, // "frequent", "average", "rare" + batchProcessingLevel: String?, // "low", "medium", "high" + additionalConfiguration: NSDictionary? ) -> Bool { - let configuration = DatadogCore.Datadog.Configuration( + var configuration = DatadogCore.Datadog.Configuration( clientToken: clientToken, env: environment, - site: { () in - switch site.lowercased() { - case "us3": return .us3 - case "us5": return .us5 - case "eu1": return .eu1 - case "ap1": return .ap1 - case "us1_fed": return .us1_fed - default: return .us1 - } - }() + site: mapSite(site), + service: service ) - - switch verbosity.lowercased() { - case "debug": DatadogCore.Datadog.verbosityLevel = .debug - case "info": DatadogCore.Datadog.verbosityLevel = .debug - case "warn": DatadogCore.Datadog.verbosityLevel = .warn - case "error": DatadogCore.Datadog.verbosityLevel = .error - default: DatadogCore.Datadog.verbosityLevel = .error - } - - DatadogCore.Datadog.initialize( - with: configuration, - trackingConsent: .granted - ) - - Logs.enable() - + // batchSize / uploadFrequency / batchProcessingLevel applied if non-nil via map helpers + // additionalConfiguration applied via _internal_mutation + DatadogCore.Datadog.verbosityLevel = mapVerbosity(verbosity) + DatadogCore.Datadog.initialize(with: configuration, trackingConsent: mapTrackingConsent(trackingConsent)) return true } } ``` +> **Note**: The Swift class is named `DdSdkNativeWrapper` internally but exported to Objective-C as `DatadogWrapper` via `@objc(DatadogWrapper)`. + ### XCFramework Build Process **Script**: `native-wrappers/ios/build.sh` @@ -319,9 +314,9 @@ namespace DatadogSdk.iOS.Binding ``` **Export Selector Mapping**: -- Swift: `initialize(clientToken:environment:service:site:verbosity:)` -- Objective-C: `initializeWithClientToken:environment:service:site:verbosity:` -- C#: `Initialize(string, string, string, string, string)` +- Swift: `initialize(clientToken:environment:service:site:verbosity:trackingConsent:batchSize:uploadFrequency:batchProcessingLevel:additionalConfiguration:)` +- Objective-C selector: `initializeWithClientToken:environment:service:site:verbosity:trackingConsent:batchSize:uploadFrequency:batchProcessingLevel:additionalConfiguration:` +- C#: `Initialize(string, string, string, string, string, string, string, string, string, NSDictionary)` Pattern: First parameter name becomes method name, subsequent become part of selector. @@ -391,55 +386,40 @@ dependencies { Kotlin companion object methods must use `@JvmStatic` to expose as static methods: ```kotlin -package com.datadog.wrapper - -import android.content.Context -import android.util.Log -import com.datadog.android.Datadog -import com.datadog.android.DatadogSite -import com.datadog.android.core.configuration.Configuration -import com.datadog.android.privacy.TrackingConsent - class DatadogWrapper { companion object { + // Mapping helpers — extracted as testable pure functions + @JvmStatic fun mapSite(site: String): DatadogSite = ... + @JvmStatic fun mapTrackingConsent(consent: String): TrackingConsent = ... + @JvmStatic fun mapVerbosity(verbosity: String): Int = ... // returns Log.* constant + @JvmStatic fun mapBatchSize(batchSize: String): BatchSize = ... + @JvmStatic fun mapUploadFrequency(uploadFrequency: String): UploadFrequency = ... + @JvmStatic fun mapBatchProcessingLevel(level: String): BatchProcessingLevel = ... + @JvmStatic fun initialize( context: Context, clientToken: String, environment: String, - service: String, + service: String?, // nullable — optional service name site: String = "us1", - verbosity: String = "error" + verbosity: String = "error", + trackingConsent: String = "pending", // "granted", "not_granted", "pending" + batchSize: String? = null, // "small", "medium", "large" + uploadFrequency: String? = null, // "frequent", "average", "rare" + batchProcessingLevel: String? = null, // "low", "medium", "high" + additionalConfiguration: Map? = null ): Boolean { return try { - val datadogSite = when (site.lowercase()) { - "us1" -> DatadogSite.US1 - "us3" -> DatadogSite.US3 - "us5" -> DatadogSite.US5 - "eu1" -> DatadogSite.EU1 - "ap1" -> DatadogSite.AP1 - "us1_fed" -> DatadogSite.US1_FED - else -> DatadogSite.US1 - } - - val configuration = Configuration.Builder( - clientToken = clientToken, - env = environment, - service = service - ) - .useSite(datadogSite) - .build() - - Datadog.initialize(context, configuration, TrackingConsent.GRANTED) - - Datadog.setVerbosity(when (verbosity.lowercase()) { - "debug" -> Log.DEBUG - "info" -> Log.INFO - "warn" -> Log.WARN - "error" -> Log.ERROR - else -> Log.ERROR - }) - + val builder = Configuration.Builder(clientToken, environment, service) + .useSite(mapSite(site)) + + // batchSize / uploadFrequency / batchProcessingLevel applied if non-nil via map helpers + // additionalConfiguration passed to builder.setAdditionalConfiguration() + // If additionalConfiguration["_dd.needsClearTextHttp"] == true, + // _InternalProxy.allowClearTextHttp(builder) is called + Datadog.initialize(context, builder.build(), mapTrackingConsent(trackingConsent)) + Datadog.setVerbosity(mapVerbosity(verbosity)) true } catch (e: Exception) { e.printStackTrace() @@ -450,6 +430,10 @@ class DatadogWrapper { } ``` +**Internal proxy support**: + +When `additionalConfiguration["_dd.needsClearTextHttp"] == true`, the Android wrapper calls `_InternalProxy.allowClearTextHttp(builder)` to configure OkHttp for plain HTTP transport. This is intended only for integration testing against local mock intake servers — not for production use. + ### AAR Build Process **Command**: `./gradlew :datadogwrapper:assembleRelease` @@ -560,39 +544,145 @@ namespace DatadogSdk.Android.Binding { ``` DatadogSdk.Maui/ ├── DatadogSdk.Maui.csproj # Multi-target project (iOS + Android) -├── DdSdkConfiguration.cs # Configuration object +├── Configuration/ # Configuration namespace +│ ├── DdSdkConfiguration.cs # Main configuration class +│ ├── FileBasedConfiguration.cs # JSON config parser (ParseJsonConfig) +│ ├── TrackingConsent.cs # Tracking consent enum +│ ├── BatchSize.cs # Batch size enum +│ ├── BatchProcessingLevel.cs # Processing level enum +│ ├── UploadFrequency.cs # Upload frequency enum +│ ├── DatadogSite.cs # Datadog site enum +│ └── SdkVerbosity.cs # SDK verbosity enum ├── DdSdk.cs # SDK initialization (wraps native DatadogWrapper) -└── DdLogs.cs # Logging API (wraps native DdLogs) +├── DdLogs.cs # Logging API (wraps native DdLogs) +└── InternalLog.cs # SDK-internal console logging ``` ### DdSdkConfiguration -Configuration object passed to `DdSdk.Initialize()`: +Configuration object passed to `DdSdk.Initialize()`. Located in `DatadogSdk.Maui.Configuration` namespace: ```csharp -public enum SdkVerbosity { DEBUG, INFO, WARN, ERROR } +namespace DatadogSdk.Maui.Configuration +{ + public class DdSdkConfiguration + { + // --- Required --- + public required string ClientToken { get; set; } + public required string Environment { get; set; } + public TrackingConsent TrackingConsent { get; set; } = TrackingConsent.Granted; + + // --- Optional --- + public Dictionary? AdditionalConfiguration { get; set; } + public BatchSize? BatchSize { get; set; } + public BatchProcessingLevel? BatchProcessingLevel { get; set; } + public string? Service { get; set; } + public DatadogSite Site { get; set; } = DatadogSite.Us1; + public UploadFrequency? UploadFrequency { get; set; } + public string? Version { get; set; } + public string? VersionSuffix { get; set; } + public SdkVerbosity? Verbosity { get; set; } + } + + public enum TrackingConsent { Granted, NotGranted, Pending } + public enum BatchSize { Small, Medium, Large } + public enum BatchProcessingLevel { Low, Medium, High } + public enum UploadFrequency { Frequent, Average, Rare } + public enum DatadogSite { Us1, Us3, Us5, Eu1, Ap1, Ap2, Us1Fed } + public enum SdkVerbosity { DEBUG, INFO, WARN, ERROR } +} +``` + +**Required fields:** +- `ClientToken` - Your Datadog client token +- `Environment` - Environment name (e.g., "prod", "staging", "dev") +- `TrackingConsent` - User tracking consent (defaults to `Granted`) + +**Optional fields:** +- `Service` - Service name for grouping logs/traces +- `Site` - Datadog site region (defaults to `Us1`) +- `BatchSize` - Size of data batches for uploads +- `BatchProcessingLevel` - CPU/memory trade-off for batch processing +- `UploadFrequency` - How often to upload data to Datadog +- `Version` - Application version string (injected as `_dd.version` in `additionalConfiguration`) +- `VersionSuffix` - Additional version identifier (injected as `_dd.version_suffix`) +- `Verbosity` - SDK logging level for debugging +- `AdditionalConfiguration` - Pass-through dictionary for platform-specific or internal keys + +**Reserved `AdditionalConfiguration` keys**: + +| Key | Type | Purpose | +|-----|------|---------| +| `_dd.version` | `string` | Set automatically from `Version` field | +| `_dd.version_suffix` | `string` | Set automatically from `VersionSuffix` field | +| `_dd.needsClearTextHttp` | `bool` | Enable plain HTTP (Android only, for integration testing) | + +`Version` and `VersionSuffix` are merged into `AdditionalConfiguration` by `BuildAdditionalConfiguration()` before the config is passed to the native layer. Any other keys in `AdditionalConfiguration` flow through unchanged. -public class DdSdkConfiguration +### FileBasedConfiguration + +Static class for parsing JSON configuration files into `DdSdkConfiguration` objects. + +```csharp +public static class FileBasedConfiguration +{ + public static DdSdkConfiguration ParseJsonConfig(string json); +} +``` + +**JSON format**: PascalCase keys, C# enum names for enum values, flat structure: +```json { - public required string ClientToken { get; set; } - public required string Environment { get; set; } - public required string Service { get; set; } - public string Site { get; set; } = "us1"; - public SdkVerbosity Verbosity { get; set; } = SdkVerbosity.ERROR; + "ClientToken": "pub-xxx", + "Environment": "prod", + "Site": "Eu1", + "Service": "my-app", + "TrackingConsent": "Granted", + "Verbosity": "DEBUG", + "BatchSize": "Medium", + "UploadFrequency": "Average", + "BatchProcessingLevel": "Medium", + "Version": "1.0.0", + "VersionSuffix": "-beta", + "AdditionalConfiguration": { "_dd.needsClearTextHttp": true } } ``` +**Implementation**: Uses `System.Text.Json` with `JsonDocument` for manual property mapping (avoids issues with `required` properties in direct deserialization). Enum fields use `Enum.TryParse(value, ignoreCase: true)`. Throws `ArgumentException` for missing required fields or invalid enum values. + ### DdSdk Wraps native `DatadogWrapper.Initialize()` with platform branching: ```csharp -// Android: passes Android Context + clientToken, environment, service, site, verbosity -// iOS: passes clientToken, environment, service, site, verbosity public static bool Initialize(DdSdkConfiguration config); ``` -Stores configuration internally so other modules (e.g. `DdLogs`) can access it. Converts `SdkVerbosity` enum to a lowercase string (e.g. `"debug"`, `"error"`) and passes it to both the native SDK and the C# layer. When `Verbosity` is `DEBUG`, logs all C# layer calls via `Console.WriteLine("[Datadog] ...")`. The native SDKs also use the verbosity to control their internal logging (iOS: `Datadog.verbosityLevel`, Android: `Datadog.setVerbosity()`). +**Initialization flow:** +1. Stores configuration and sets `InternalLog.Verbosity` +2. Converts all configuration enums to lowercase strings for the native bridge +3. Calls `BuildAdditionalConfiguration(additionalConfig, version, versionSuffix)` to merge `Version` and `VersionSuffix` into the additional config dictionary +4. Marshals the merged config to native platform: + - **Android**: `IDictionary` (values type-boxed) + - **iOS**: `NSDictionary` (values via `NSObject.FromObject`) +5. Calls `NativeDatadogWrapper.Initialize(...)` with all parameters + +**`BuildAdditionalConfiguration` helper** (internal, testable): + +```csharp +internal static Dictionary? BuildAdditionalConfiguration( + Dictionary? additionalConfiguration, + string? version, + string? versionSuffix) +``` + +- Copies `additionalConfiguration` (does not mutate source) +- Injects `_dd.version` if `version != null` +- Injects `_dd.version_suffix` if `versionSuffix != null` +- All other keys in `additionalConfiguration` flow through unchanged +- Returns `null` when all inputs are null + +When `Verbosity` is `DEBUG`, the SDK logs all C# layer calls via `Console.WriteLine("[Datadog] ...")`. The native SDKs also use the verbosity to control their internal logging (iOS: `Datadog.verbosityLevel`, Android: `Datadog.setVerbosity()`). ### DdLogs @@ -723,15 +813,19 @@ public static void LogWithAttributes(string level, string message, Dictionary? AdditionalConfiguration { get; set; } + public BatchSize? BatchSize { get; set; } + public BatchProcessingLevel? BatchProcessingLevel { get; set; } + public string? Service { get; set; } + public DatadogSite Site { get; set; } = DatadogSite.Us1; + public UploadFrequency? UploadFrequency { get; set; } + public string? Version { get; set; } + public string? VersionSuffix { get; set; } + public SdkVerbosity? Verbosity { get; set; } + } +} diff --git a/bindings/DatadogSdk.Maui/Configuration/FileBasedConfiguration.cs b/bindings/DatadogSdk.Maui/Configuration/FileBasedConfiguration.cs new file mode 100644 index 0000000..c939a82 --- /dev/null +++ b/bindings/DatadogSdk.Maui/Configuration/FileBasedConfiguration.cs @@ -0,0 +1,124 @@ +using System.Collections.Generic; +using System.Text.Json; + +namespace DatadogSdk.Maui.Configuration +{ + public static class FileBasedConfiguration + { + public static DdSdkConfiguration ParseJsonConfig(string json) + { + JsonDocument doc = JsonDocument.Parse(json); + JsonElement root = doc.RootElement; + + // Required fields + string clientToken = GetRequiredString(root, "ClientToken"); + string environment = GetRequiredString(root, "Environment"); + + var config = new DdSdkConfiguration + { + ClientToken = clientToken, + Environment = environment + }; + + // Optional string fields + if (TryGetString(root, "Service", out string? service)) + config.Service = service; + + if (TryGetString(root, "Version", out string? version)) + config.Version = version; + + if (TryGetString(root, "VersionSuffix", out string? versionSuffix)) + config.VersionSuffix = versionSuffix; + + // Optional enum fields + if (TryParseEnum(root, "Site", out DatadogSite site)) + config.Site = site; + + if (TryParseEnum(root, "TrackingConsent", out TrackingConsent consent)) + config.TrackingConsent = consent; + + if (TryParseEnum(root, "Verbosity", out SdkVerbosity verbosity)) + config.Verbosity = verbosity; + + if (TryParseEnum(root, "BatchSize", out BatchSize batchSize)) + config.BatchSize = batchSize; + + if (TryParseEnum(root, "UploadFrequency", out UploadFrequency uploadFrequency)) + config.UploadFrequency = uploadFrequency; + + if (TryParseEnum(root, "BatchProcessingLevel", out BatchProcessingLevel batchProcessingLevel)) + config.BatchProcessingLevel = batchProcessingLevel; + + // Optional additional configuration + if (root.TryGetProperty("AdditionalConfiguration", out JsonElement additionalElement) + && additionalElement.ValueKind == JsonValueKind.Object) + { + config.AdditionalConfiguration = ParseAdditionalConfiguration(additionalElement); + } + + return config; + } + + private static string GetRequiredString(JsonElement root, string propertyName) + { + if (!root.TryGetProperty(propertyName, out JsonElement element) + || element.ValueKind != JsonValueKind.String + || string.IsNullOrWhiteSpace(element.GetString())) + { + throw new ArgumentException($"Required property '{propertyName}' is missing or empty."); + } + + return element.GetString()!; + } + + private static bool TryGetString(JsonElement root, string propertyName, out string? value) + { + value = null; + if (root.TryGetProperty(propertyName, out JsonElement element) + && element.ValueKind == JsonValueKind.String) + { + value = element.GetString(); + return true; + } + return false; + } + + private static bool TryParseEnum(JsonElement root, string propertyName, out T value) where T : struct, Enum + { + value = default; + if (root.TryGetProperty(propertyName, out JsonElement element) + && element.ValueKind == JsonValueKind.String) + { + string? raw = element.GetString(); + if (raw != null && Enum.TryParse(raw, ignoreCase: true, out T parsed)) + { + value = parsed; + return true; + } + + throw new ArgumentException( + $"Invalid value '{raw}' for property '{propertyName}'. " + + $"Valid values are: {string.Join(", ", Enum.GetNames(typeof(T)))}."); + } + return false; + } + + private static Dictionary ParseAdditionalConfiguration(JsonElement element) + { + var dict = new Dictionary(); + foreach (JsonProperty property in element.EnumerateObject()) + { + dict[property.Name] = property.Value.ValueKind switch + { + JsonValueKind.String => property.Value.GetString()!, + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Number when property.Value.TryGetInt64(out long l) => l, + JsonValueKind.Number => property.Value.GetDouble(), + _ => property.Value.GetRawText() + }; + } + return dict; + } + } +} diff --git a/bindings/DatadogSdk.Maui/Configuration/TrackingConsent.cs b/bindings/DatadogSdk.Maui/Configuration/TrackingConsent.cs new file mode 100644 index 0000000..694813f --- /dev/null +++ b/bindings/DatadogSdk.Maui/Configuration/TrackingConsent.cs @@ -0,0 +1,9 @@ +namespace DatadogSdk.Maui.Configuration +{ + public enum TrackingConsent + { + Granted, + NotGranted, + Pending + } +} diff --git a/bindings/DatadogSdk.Maui/Configuration/UploadFrequency.cs b/bindings/DatadogSdk.Maui/Configuration/UploadFrequency.cs new file mode 100644 index 0000000..c707277 --- /dev/null +++ b/bindings/DatadogSdk.Maui/Configuration/UploadFrequency.cs @@ -0,0 +1,9 @@ +namespace DatadogSdk.Maui.Configuration +{ + public enum UploadFrequency + { + Frequent, + Average, + Rare + } +} diff --git a/bindings/DatadogSdk.Maui/DdSdk.cs b/bindings/DatadogSdk.Maui/DdSdk.cs index d3baa32..d0a3478 100644 --- a/bindings/DatadogSdk.Maui/DdSdk.cs +++ b/bindings/DatadogSdk.Maui/DdSdk.cs @@ -1,47 +1,187 @@ #if ANDROID using NativeDatadogWrapper = DatadogSdk.Android.Binding.DatadogWrapper; #elif IOS +using Foundation; using NativeDatadogWrapper = DatadogSdk.iOS.Binding.DatadogWrapper; #endif +using System.Collections.Generic; +using System.Linq; +using DatadogSdk.Maui.Configuration; namespace DatadogSdk.Maui { public static class DdSdk { + internal interface INativeBridge + { + bool Initialize( + string clientToken, + string environment, + string? service, + string site, + string verbosity, + string trackingConsent, + string? batchSize, + string? uploadFrequency, + string? batchProcessingLevel, + Dictionary? additionalConfiguration); + } + internal static DdSdkConfiguration? Configuration { get; private set; } + internal static INativeBridge? testBridge; public static bool Initialize(DdSdkConfiguration config) { Configuration = config; - InternalLog.Verbosity = config.Verbosity; + // Convert enums to lowercase strings for native bridge var verbosity = (config.Verbosity ?? SdkVerbosity.ERROR).ToString().ToLowerInvariant(); + var site = ConvertSite(config.Site); + var trackingConsent = ConvertTrackingConsent(config.TrackingConsent); + var batchSize = config.BatchSize?.ToString().ToLowerInvariant(); + var uploadFrequency = config.UploadFrequency?.ToString().ToLowerInvariant(); + var batchProcessingLevel = config.BatchProcessingLevel?.ToString().ToLowerInvariant(); - InternalLog.Log($"DdSdk.Initialize called with service={config.Service}, env={config.Environment}, site={config.Site}", SdkVerbosity.DEBUG); + InternalLog.Log( + $"DdSdk.Initialize called with env={config.Environment}, site={site}, consent={trackingConsent}", + SdkVerbosity.DEBUG + ); + var mergedConfig = BuildAdditionalConfiguration( + config.AdditionalConfiguration, + config.Version, + config.VersionSuffix + ); + + var sdkInitialized = false; + + if (testBridge is not null) + { + sdkInitialized = testBridge.Initialize( + config.ClientToken, + config.Environment, + config.Service, + site, + verbosity, + trackingConsent, + batchSize, + uploadFrequency, + batchProcessingLevel, + mergedConfig); + } + else + { #if ANDROID var context = global::Android.App.Application.Context; - var result = NativeDatadogWrapper.Initialize( + + IDictionary? androidConfig = null; + if (mergedConfig != null) + { + androidConfig = new Dictionary(); + foreach (var kvp in mergedConfig) + { + androidConfig[kvp.Key] = kvp.Value switch + { + string s => new Java.Lang.String(s), + int i => new Java.Lang.Integer(i), + bool b => new Java.Lang.Boolean(b), + long l => new Java.Lang.Long(l), + double d => new Java.Lang.Double(d), + _ => new Java.Lang.String(kvp.Value?.ToString() ?? "") + }; + } + } + + sdkInitialized = NativeDatadogWrapper.Initialize( context, config.ClientToken, config.Environment, config.Service, - config.Site, - verbosity + site, + verbosity, + trackingConsent, + batchSize, + uploadFrequency, + batchProcessingLevel, + androidConfig ); #elif IOS - var result = NativeDatadogWrapper.Initialize( + NSDictionary? iosConfig = null; + if (mergedConfig != null) + { + iosConfig = NSDictionary.FromObjectsAndKeys( + mergedConfig.Values.Select(v => NSObject.FromObject(v)).ToArray(), + mergedConfig.Keys.Select(k => (NSObject)new NSString(k)).ToArray() + ); + } + + sdkInitialized = NativeDatadogWrapper.Initialize( config.ClientToken, config.Environment, config.Service, - config.Site, - verbosity + site, + verbosity, + trackingConsent, + batchSize, + uploadFrequency, + batchProcessingLevel, + iosConfig ); #endif + } + + InternalLog.Log($"DdSdk.Initialize completed: {sdkInitialized}", SdkVerbosity.INFO); + return sdkInitialized; + } + + internal static string ConvertSite(DatadogSite site) => site switch + { + DatadogSite.Us1 => "us1", + DatadogSite.Us3 => "us3", + DatadogSite.Us5 => "us5", + DatadogSite.Eu1 => "eu1", + DatadogSite.Ap1 => "ap1", + DatadogSite.Ap2 => "ap2", + DatadogSite.Us1Fed => "us1_fed", + _ => "us1" + }; + + internal static string ConvertTrackingConsent(TrackingConsent consent) => consent switch + { + TrackingConsent.Granted => "granted", + TrackingConsent.NotGranted => "not_granted", + TrackingConsent.Pending => "pending", + _ => "pending" + }; + + /// Merges Version and VersionSuffix into the additionalConfiguration dictionary + /// as the reserved keys _dd.version and _dd.version_suffix. + /// Any other internal keys are passed directly + /// via AdditionalConfiguration and flow through unchanged. + /// Returns null when all three inputs are null/empty. + internal static Dictionary? BuildAdditionalConfiguration( + Dictionary? additionalConfiguration, + string? version, + string? versionSuffix) + { + Dictionary? merged = additionalConfiguration != null + ? new(additionalConfiguration) + : null; + + if (version != null) + { + merged ??= []; + merged["_dd.version"] = version; + } + + if (versionSuffix != null) + { + merged ??= []; + merged["_dd.version_suffix"] = versionSuffix; + } - InternalLog.Log($"DdSdk.Initialize completed: {result}", SdkVerbosity.INFO); - return result; + return merged; } } } diff --git a/bindings/DatadogSdk.Maui/DdSdkConfiguration.cs b/bindings/DatadogSdk.Maui/DdSdkConfiguration.cs deleted file mode 100644 index 08332fb..0000000 --- a/bindings/DatadogSdk.Maui/DdSdkConfiguration.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace DatadogSdk.Maui -{ - public class DdSdkConfiguration - { - public required string ClientToken { get; set; } - public required string Environment { get; set; } - public required string Service { get; set; } - public string Site { get; set; } = "us1"; - public SdkVerbosity? Verbosity { get; set; } = null; - } -} diff --git a/bindings/DatadogSdk.iOS.Binding/ApiDefinition.Core.cs b/bindings/DatadogSdk.iOS.Binding/ApiDefinition.Core.cs index 0287c04..4b7e597 100644 --- a/bindings/DatadogSdk.iOS.Binding/ApiDefinition.Core.cs +++ b/bindings/DatadogSdk.iOS.Binding/ApiDefinition.Core.cs @@ -9,7 +9,18 @@ namespace DatadogSdk.iOS.Binding interface DatadogWrapper { [Static] - [Export("initializeWithClientToken:environment:service:site:verbosity:")] - bool Initialize(string clientToken, string environment, string service, string site, string verbosity); + [Export("initializeWithClientToken:environment:service:site:verbosity:trackingConsent:batchSize:uploadFrequency:batchProcessingLevel:additionalConfiguration:")] + bool Initialize( + string clientToken, + string environment, + [NullAllowed] string service, + string site, + string verbosity, + string trackingConsent, + [NullAllowed] string batchSize, + [NullAllowed] string uploadFrequency, + [NullAllowed] string batchProcessingLevel, + [NullAllowed] NSDictionary additionalConfiguration + ); } } diff --git a/build.sh b/build.sh index be51f0f..1def5c9 100755 --- a/build.sh +++ b/build.sh @@ -35,7 +35,6 @@ cd "$SCRIPT_DIR" # Parse command line arguments FORMAT_ONLY=false CHECK_FORMAT=false -CLEAN=false while [[ $# -gt 0 ]]; do case $1 in @@ -47,17 +46,12 @@ while [[ $# -gt 0 ]]; do CHECK_FORMAT=true shift ;; - --clean) - CLEAN=true - shift - ;; -h|--help) echo "Usage: ./build.sh [OPTIONS]" echo "" echo "Options:" echo " --format Auto-format all C# code and exit" echo " --check-format Check C# formatting without modifying files" - echo " --clean Clean all build artifacts before building" echo " -h, --help Show this help message" exit 0 ;; @@ -101,6 +95,7 @@ cd native-wrappers/ios/DatadogWrapper # Clean previous build log_info "Cleaning previous iOS build..." +swift package clean 2>/dev/null || true rm -rf .build .swiftpm # Build XCFramework using existing script diff --git a/check.sh b/check.sh new file mode 100755 index 0000000..9dfbd10 --- /dev/null +++ b/check.sh @@ -0,0 +1,181 @@ +#!/bin/bash +set -e # Exit on error + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Helper functions +log_section() { + echo "" + echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${BLUE}▶ $1${NC}" + echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +} + +log_info() { + echo -e "${GREEN}✓${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}⚠${NC} $1" +} + +log_error() { + echo -e "${RED}✗${NC} $1" +} + +log_result() { + local name=$1 + local exit_code=$2 + if [ "$exit_code" -eq 0 ]; then + echo -e " ${GREEN}✓${NC} $name" + else + echo -e " ${RED}✗${NC} $name" + fi +} + +# Get script directory +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +cd "$SCRIPT_DIR" + +# Parse command line arguments +RUN_MAUI=false +RUN_IOS=false +RUN_ANDROID=false +RUN_ALL=true + +while [[ $# -gt 0 ]]; do + case $1 in + --maui) + RUN_MAUI=true + RUN_ALL=false + shift + ;; + --ios) + RUN_IOS=true + RUN_ALL=false + shift + ;; + --android) + RUN_ANDROID=true + RUN_ALL=false + shift + ;; + -h|--help) + echo "Usage: ./check.sh [OPTIONS]" + echo "" + echo "Runs test suites for the Datadog MAUI SDK." + echo "With no options, all test suites are run." + echo "" + echo "Options:" + echo " --maui Run C# unit tests only" + echo " --ios Run iOS Swift tests only" + echo " --android Run Android Kotlin tests only" + echo " -h, --help Show this help message" + echo "" + echo "Multiple flags can be combined:" + echo " ./check.sh --maui --android" + exit 0 + ;; + *) + log_error "Unknown option: $1" + echo "Run ./check.sh --help for usage" + exit 1 + ;; + esac +done + +# If no specific flag was set, run everything +if [ "$RUN_ALL" = true ]; then + RUN_MAUI=true + RUN_IOS=true + RUN_ANDROID=true +fi + +# Track results +MAUI_RESULT=-1 +IOS_RESULT=-1 +ANDROID_RESULT=-1 + +# ============================================================================ +# C# / MAUI Tests +# ============================================================================ +if [ "$RUN_MAUI" = true ]; then + log_section "C# Tests (xUnit)" + + log_info "Running dotnet test..." + if dotnet test "$SCRIPT_DIR/tests/DatadogSdk.Maui.Tests/"; then + MAUI_RESULT=0 + log_info "C# tests passed" + else + MAUI_RESULT=1 + log_error "C# tests failed" + fi +fi + +# ============================================================================ +# iOS Swift Tests +# ============================================================================ +if [ "$RUN_IOS" = true ]; then + log_section "iOS Tests (XCTest)" + + log_info "Running swift test..." + if (cd "$SCRIPT_DIR/native-wrappers/ios/DatadogWrapper" && swift test); then + IOS_RESULT=0 + log_info "iOS tests passed" + else + IOS_RESULT=1 + log_error "iOS tests failed" + fi +fi + +# ============================================================================ +# Android Kotlin Tests +# ============================================================================ +if [ "$RUN_ANDROID" = true ]; then + log_section "Android Tests (JUnit + MockK)" + + log_info "Running gradlew test..." + if (cd "$SCRIPT_DIR/native-wrappers/android" && ./gradlew :datadogwrapper:test); then + ANDROID_RESULT=0 + log_info "Android tests passed" + else + ANDROID_RESULT=1 + log_error "Android tests failed" + fi +fi + +# ============================================================================ +# Summary +# ============================================================================ +log_section "Results" + +FAILED=0 + +if [ "$RUN_MAUI" = true ]; then + log_result "C# (xUnit)" $MAUI_RESULT + [ "$MAUI_RESULT" -ne 0 ] && FAILED=1 +fi + +if [ "$RUN_IOS" = true ]; then + log_result "iOS (XCTest)" $IOS_RESULT + [ "$IOS_RESULT" -ne 0 ] && FAILED=1 +fi + +if [ "$RUN_ANDROID" = true ]; then + log_result "Android (JUnit)" $ANDROID_RESULT + [ "$ANDROID_RESULT" -ne 0 ] && FAILED=1 +fi + +echo "" + +if [ "$FAILED" -ne 0 ]; then + echo -e "${RED}Some test suites failed.${NC}" + exit 1 +else + echo -e "${GREEN}All test suites passed.${NC}" +fi diff --git a/example.slnx b/example.slnx index 258b3e3..2795a75 100644 --- a/example.slnx +++ b/example.slnx @@ -6,4 +6,5 @@ + diff --git a/example/MauiProgram.cs b/example/MauiProgram.cs index e2cc490..956e2d4 100644 --- a/example/MauiProgram.cs +++ b/example/MauiProgram.cs @@ -1,4 +1,5 @@ -using DatadogSdk.Maui; +using DatadogSdk.Maui; +using DatadogSdk.Maui.Configuration; using Microsoft.Extensions.Logging; namespace example; @@ -31,7 +32,14 @@ public static MauiApp CreateMauiApp() ClientToken = clientToken, Environment = environment, Service = "datadog-maui-test", - Verbosity = SdkVerbosity.INFO + Site = DatadogSite.Us1, + TrackingConsent = TrackingConsent.Granted, + Verbosity = SdkVerbosity.DEBUG, + // Uncomment to test against a local mock HTTP server (see README for setup): + // AdditionalConfiguration = new Dictionary + // { + // { "_dd.needsClearTextHttp", true } + // }, }); return builder.Build(); diff --git a/example/Platforms/Android/AndroidManifest.xml b/example/Platforms/Android/AndroidManifest.xml index bdec9b5..a11247a 100644 --- a/example/Platforms/Android/AndroidManifest.xml +++ b/example/Platforms/Android/AndroidManifest.xml @@ -1,6 +1,6 @@  - + \ No newline at end of file diff --git a/example/Platforms/Android/Resources/xml/network_security_config.xml b/example/Platforms/Android/Resources/xml/network_security_config.xml new file mode 100644 index 0000000..e1ca5ab --- /dev/null +++ b/example/Platforms/Android/Resources/xml/network_security_config.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/example/Resources/Raw/appsettings.json.example b/example/Resources/Raw/appsettings.json.example index 6f2c2a0..4cae457 100644 --- a/example/Resources/Raw/appsettings.json.example +++ b/example/Resources/Raw/appsettings.json.example @@ -1,7 +1,7 @@ { "Datadog": { - "ClientToken": "YOUR_CLIENT_TOKEN", - "Environment": "dev", - "ApplicationId": "YOUR_APPLICATION_ID" + "ClientToken": "CLIENT_TOKEN", + "Environment": "ENVIRONMENT", + "ApplicationId": "APPLICATION_ID" } } diff --git a/example/build.sh b/example/build.sh index d9a98d5..8e4b244 100755 --- a/example/build.sh +++ b/example/build.sh @@ -35,7 +35,6 @@ cd "$SCRIPT_DIR" # Parse command line arguments TARGET="" RUN_APP=false -CLEAN=false show_help() { echo "Usage: ./build.sh [OPTIONS]" @@ -44,13 +43,12 @@ show_help() { echo " --ios Build for iOS (default if no target specified)" echo " --android Build for Android" echo " --run Run the app after building" - echo " --clean Clean before building" echo " -h, --help Show this help message" echo "" echo "Examples:" echo " ./build.sh --ios --run # Build and run iOS app" - echo " ./build.sh --android --clean # Clean and build Android app" - echo " ./build.sh --clean --ios --run # Clean, build, and run iOS app" + echo " ./build.sh --android # Build Android app" + echo " ./build.sh --ios --run # Build and run iOS app" } # Parse arguments @@ -68,10 +66,6 @@ while [[ $# -gt 0 ]]; do RUN_APP=true shift ;; - --clean) - CLEAN=true - shift - ;; -h|--help) show_help exit 0 diff --git a/native-wrappers/android/datadogwrapper/build.gradle.kts b/native-wrappers/android/datadogwrapper/build.gradle.kts index c8131d2..13b492b 100644 --- a/native-wrappers/android/datadogwrapper/build.gradle.kts +++ b/native-wrappers/android/datadogwrapper/build.gradle.kts @@ -37,4 +37,8 @@ dependencies { // Dependencies will be needed at runtime but won't be in the binding API implementation("com.datadoghq:dd-sdk-android-core:3.5.0") implementation("com.datadoghq:dd-sdk-android-logs:3.5.0") + + // Test dependencies + testImplementation("junit:junit:4.13.2") + testImplementation("io.mockk:mockk:1.13.16") } diff --git a/native-wrappers/android/datadogwrapper/src/main/kotlin/com/datadog/wrapper/DatadogWrapper.kt b/native-wrappers/android/datadogwrapper/src/main/kotlin/com/datadog/wrapper/DatadogWrapper.kt index 9a5797a..64a6937 100644 --- a/native-wrappers/android/datadogwrapper/src/main/kotlin/com/datadog/wrapper/DatadogWrapper.kt +++ b/native-wrappers/android/datadogwrapper/src/main/kotlin/com/datadog/wrapper/DatadogWrapper.kt @@ -4,49 +4,114 @@ import android.content.Context import android.util.Log import com.datadog.android.Datadog import com.datadog.android.DatadogSite +import com.datadog.android._InternalProxy +import com.datadog.android.core.configuration.BatchProcessingLevel +import com.datadog.android.core.configuration.BatchSize import com.datadog.android.core.configuration.Configuration +import com.datadog.android.core.configuration.UploadFrequency import com.datadog.android.privacy.TrackingConsent class DatadogWrapper { companion object { + + // -- Mapping helpers -- + + @JvmStatic + fun mapSite(site: String): DatadogSite = when (site.lowercase()) { + "us1" -> DatadogSite.US1 + "us3" -> DatadogSite.US3 + "us5" -> DatadogSite.US5 + "eu1" -> DatadogSite.EU1 + "ap1" -> DatadogSite.AP1 + "ap2" -> DatadogSite.AP2 + "us1_fed" -> DatadogSite.US1_FED + else -> DatadogSite.US1 + } + + @JvmStatic + fun mapTrackingConsent(consent: String): TrackingConsent = when (consent.lowercase()) { + "granted" -> TrackingConsent.GRANTED + "not_granted" -> TrackingConsent.NOT_GRANTED + else -> TrackingConsent.PENDING + } + + @JvmStatic + fun mapVerbosity(verbosity: String): Int = when (verbosity.lowercase()) { + "debug" -> Log.DEBUG + "info" -> Log.INFO + "warn" -> Log.WARN + "error" -> Log.ERROR + else -> Log.ERROR + } + + @JvmStatic + fun mapBatchSize(batchSize: String): BatchSize = when (batchSize.lowercase()) { + "small" -> BatchSize.SMALL + "large" -> BatchSize.LARGE + else -> BatchSize.MEDIUM + } + + @JvmStatic + fun mapUploadFrequency(uploadFrequency: String): UploadFrequency = when (uploadFrequency.lowercase()) { + "frequent" -> UploadFrequency.FREQUENT + "rare" -> UploadFrequency.RARE + else -> UploadFrequency.AVERAGE + } + + @JvmStatic + fun mapBatchProcessingLevel(level: String): BatchProcessingLevel = when (level.lowercase()) { + "low" -> BatchProcessingLevel.LOW + "high" -> BatchProcessingLevel.HIGH + else -> BatchProcessingLevel.MEDIUM + } + + // -- Initialization -- + @JvmStatic fun initialize( context: Context, clientToken: String, environment: String, - service: String, + service: String?, site: String = "us1", - verbosity: String = "error" + verbosity: String = "error", + trackingConsent: String = "pending", + batchSize: String? = null, + uploadFrequency: String? = null, + batchProcessingLevel: String? = null, + additionalConfiguration: Map? = null ): Boolean { return try { - val datadogSite = when (site.lowercase()) { - "us1" -> DatadogSite.US1 - "us3" -> DatadogSite.US3 - "us5" -> DatadogSite.US5 - "eu1" -> DatadogSite.EU1 - "ap1" -> DatadogSite.AP1 - "us1_fed" -> DatadogSite.US1_FED - else -> DatadogSite.US1 - } - - val configuration = Configuration.Builder( + val builder = Configuration.Builder( clientToken = clientToken, env = environment, service = service ) - .useSite(datadogSite) - .build() - - Datadog.initialize(context, configuration, TrackingConsent.GRANTED) - - // Set SDK verbosity level - Datadog.setVerbosity(when (verbosity.lowercase()) { - "debug" -> Log.DEBUG - "info" -> Log.INFO - "warn" -> Log.WARN - "error" -> Log.ERROR - else -> Log.ERROR - }) + .useSite(mapSite(site)) + + batchSize?.let { + builder.setBatchSize(mapBatchSize(it)) + } + + uploadFrequency?.let { + builder.setUploadFrequency(mapUploadFrequency(it)) + } + + batchProcessingLevel?.let { + builder.setBatchProcessingLevel(mapBatchProcessingLevel(it)) + } + + additionalConfiguration?.let { + builder.setAdditionalConfiguration(it) + } + + if (additionalConfiguration?.get("_dd.needsClearTextHttp") == true) { + _InternalProxy.allowClearTextHttp(builder) + } + + Datadog.initialize(context, builder.build(), mapTrackingConsent(trackingConsent)) + + Datadog.setVerbosity(mapVerbosity(verbosity)) true } catch (e: Exception) { diff --git a/native-wrappers/android/datadogwrapper/src/test/kotlin/com/datadog/wrapper/DatadogWrapperTest.kt b/native-wrappers/android/datadogwrapper/src/test/kotlin/com/datadog/wrapper/DatadogWrapperTest.kt new file mode 100644 index 0000000..34b9a59 --- /dev/null +++ b/native-wrappers/android/datadogwrapper/src/test/kotlin/com/datadog/wrapper/DatadogWrapperTest.kt @@ -0,0 +1,269 @@ +package com.datadog.wrapper + +import android.content.Context +import android.util.Log +import com.datadog.android.Datadog +import com.datadog.android.DatadogSite +import com.datadog.android.core.configuration.BatchProcessingLevel +import com.datadog.android.core.configuration.BatchSize +import com.datadog.android.core.configuration.Configuration +import com.datadog.android.core.configuration.UploadFrequency +import com.datadog.android.privacy.TrackingConsent +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import io.mockk.verify +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class DatadogWrapperTest { + + // -- Site mapping -- + + @Test + fun mapSite_us1() { + assertEquals(DatadogSite.US1, DatadogWrapper.mapSite("us1")) + } + + @Test + fun mapSite_us3() { + assertEquals(DatadogSite.US3, DatadogWrapper.mapSite("us3")) + } + + @Test + fun mapSite_us5() { + assertEquals(DatadogSite.US5, DatadogWrapper.mapSite("us5")) + } + + @Test + fun mapSite_eu1() { + assertEquals(DatadogSite.EU1, DatadogWrapper.mapSite("eu1")) + } + + @Test + fun mapSite_ap1() { + assertEquals(DatadogSite.AP1, DatadogWrapper.mapSite("ap1")) + } + + @Test + fun mapSite_ap2() { + assertEquals(DatadogSite.AP2, DatadogWrapper.mapSite("ap2")) + } + + @Test + fun mapSite_us1Fed() { + assertEquals(DatadogSite.US1_FED, DatadogWrapper.mapSite("us1_fed")) + } + + @Test + fun mapSite_unknownDefaultsToUs1() { + assertEquals(DatadogSite.US1, DatadogWrapper.mapSite("invalid")) + } + + @Test + fun mapSite_isCaseInsensitive() { + assertEquals(DatadogSite.EU1, DatadogWrapper.mapSite("EU1")) + } + + // -- Tracking consent mapping -- + + @Test + fun mapTrackingConsent_granted() { + assertEquals(TrackingConsent.GRANTED, DatadogWrapper.mapTrackingConsent("granted")) + } + + @Test + fun mapTrackingConsent_notGranted() { + assertEquals(TrackingConsent.NOT_GRANTED, DatadogWrapper.mapTrackingConsent("not_granted")) + } + + @Test + fun mapTrackingConsent_pending() { + assertEquals(TrackingConsent.PENDING, DatadogWrapper.mapTrackingConsent("pending")) + } + + @Test + fun mapTrackingConsent_unknownDefaultsToPending() { + assertEquals(TrackingConsent.PENDING, DatadogWrapper.mapTrackingConsent("invalid")) + } + + // -- Verbosity mapping -- + + @Test + fun mapVerbosity_debug() { + assertEquals(Log.DEBUG, DatadogWrapper.mapVerbosity("debug")) + } + + @Test + fun mapVerbosity_info() { + assertEquals(Log.INFO, DatadogWrapper.mapVerbosity("info")) + } + + @Test + fun mapVerbosity_warn() { + assertEquals(Log.WARN, DatadogWrapper.mapVerbosity("warn")) + } + + @Test + fun mapVerbosity_error() { + assertEquals(Log.ERROR, DatadogWrapper.mapVerbosity("error")) + } + + @Test + fun mapVerbosity_unknownDefaultsToError() { + assertEquals(Log.ERROR, DatadogWrapper.mapVerbosity("invalid")) + } + + // -- Batch size mapping -- + + @Test + fun mapBatchSize_small() { + assertEquals(BatchSize.SMALL, DatadogWrapper.mapBatchSize("small")) + } + + @Test + fun mapBatchSize_medium() { + assertEquals(BatchSize.MEDIUM, DatadogWrapper.mapBatchSize("medium")) + } + + @Test + fun mapBatchSize_large() { + assertEquals(BatchSize.LARGE, DatadogWrapper.mapBatchSize("large")) + } + + @Test + fun mapBatchSize_unknownDefaultsToMedium() { + assertEquals(BatchSize.MEDIUM, DatadogWrapper.mapBatchSize("invalid")) + } + + // -- Upload frequency mapping -- + + @Test + fun mapUploadFrequency_frequent() { + assertEquals(UploadFrequency.FREQUENT, DatadogWrapper.mapUploadFrequency("frequent")) + } + + @Test + fun mapUploadFrequency_average() { + assertEquals(UploadFrequency.AVERAGE, DatadogWrapper.mapUploadFrequency("average")) + } + + @Test + fun mapUploadFrequency_rare() { + assertEquals(UploadFrequency.RARE, DatadogWrapper.mapUploadFrequency("rare")) + } + + @Test + fun mapUploadFrequency_unknownDefaultsToAverage() { + assertEquals(UploadFrequency.AVERAGE, DatadogWrapper.mapUploadFrequency("invalid")) + } + + // -- Batch processing level mapping -- + + @Test + fun mapBatchProcessingLevel_low() { + assertEquals(BatchProcessingLevel.LOW, DatadogWrapper.mapBatchProcessingLevel("low")) + } + + @Test + fun mapBatchProcessingLevel_medium() { + assertEquals(BatchProcessingLevel.MEDIUM, DatadogWrapper.mapBatchProcessingLevel("medium")) + } + + @Test + fun mapBatchProcessingLevel_high() { + assertEquals(BatchProcessingLevel.HIGH, DatadogWrapper.mapBatchProcessingLevel("high")) + } + + @Test + fun mapBatchProcessingLevel_unknownDefaultsToMedium() { + assertEquals(BatchProcessingLevel.MEDIUM, DatadogWrapper.mapBatchProcessingLevel("invalid")) + } + + // -- SDK initialization -- + + private val mockContext: Context = mockk(relaxed = true) + + private fun withMockedDatadog(block: () -> Unit) { + mockkStatic(Datadog::class) + every { Datadog.initialize(any(), any(), any()) } returns null + every { Datadog.setVerbosity(any()) } returns Unit + try { + block() + } finally { + unmockkStatic(Datadog::class) + } + } + + @Test + fun initialize_callsDatadogInitialize() { + withMockedDatadog { + val result = DatadogWrapper.initialize( + context = mockContext, + clientToken = "pub-test-token", + environment = "test", + service = null, + site = "us1", + verbosity = "error", + trackingConsent = "pending" + ) + + assertTrue(result) + verify { Datadog.initialize(mockContext, any(), TrackingConsent.PENDING) } + } + } + + @Test + fun initialize_setsVerbosityLevel() { + withMockedDatadog { + DatadogWrapper.initialize( + context = mockContext, + clientToken = "pub-test-token", + environment = "test", + service = null, + site = "us1", + verbosity = "warn", + trackingConsent = "pending" + ) + + verify { Datadog.setVerbosity(Log.WARN) } + } + } + + @Test + fun initialize_withDebugVerbosity_setsDebugLevel() { + withMockedDatadog { + DatadogWrapper.initialize( + context = mockContext, + clientToken = "pub-test-token", + environment = "test", + service = null, + site = "us1", + verbosity = "debug", + trackingConsent = "pending" + ) + + verify { Datadog.setVerbosity(Log.DEBUG) } + } + } + + @Test + fun initialize_withGrantedConsent_passesGrantedToDatadog() { + withMockedDatadog { + DatadogWrapper.initialize( + context = mockContext, + clientToken = "pub-test-token", + environment = "test", + service = null, + site = "us1", + verbosity = "error", + trackingConsent = "granted" + ) + + verify { Datadog.initialize(mockContext, any(), TrackingConsent.GRANTED) } + } + } +} diff --git a/native-wrappers/ios/DatadogWrapper/Package.swift b/native-wrappers/ios/DatadogWrapper/Package.swift index 23ba117..f291337 100644 --- a/native-wrappers/ios/DatadogWrapper/Package.swift +++ b/native-wrappers/ios/DatadogWrapper/Package.swift @@ -4,7 +4,9 @@ import PackageDescription let package = Package( name: "DatadogWrapper", platforms: [ - .iOS(.v12) + .iOS(.v12), + // macOS is required to run unit tests locally via `swift test` + .macOS(.v12) ], products: [ .library( @@ -23,6 +25,11 @@ let package = Package( .product(name: "DatadogLogs", package: "dd-sdk-ios") ], path: "Sources/DatadogWrapper" + ), + .testTarget( + name: "DatadogWrapperTests", + dependencies: ["DatadogWrapper"], + path: "Tests/DatadogWrapperTests" ) ] ) diff --git a/native-wrappers/ios/DatadogWrapper/Sources/DatadogWrapper/DatadogWrapper.swift b/native-wrappers/ios/DatadogWrapper/Sources/DatadogWrapper/DatadogWrapper.swift index e85552e..68dc136 100644 --- a/native-wrappers/ios/DatadogWrapper/Sources/DatadogWrapper/DatadogWrapper.swift +++ b/native-wrappers/ios/DatadogWrapper/Sources/DatadogWrapper/DatadogWrapper.swift @@ -1,64 +1,128 @@ import Foundation import DatadogCore +import DatadogInternal @objc(DatadogWrapper) -public class DatadogWrapper: NSObject { +public class DdSdkNativeWrapper: NSObject { - // Store service name for use by feature modules - private static var currentServiceName: String = "dd-sdk-maui" + // MARK: - Mapping helpers - /// Initialize the Datadog SDK with basic configuration + static func mapSite(_ site: String) -> DatadogSite { + switch site.lowercased() { + case "us3": return .us3 + case "us5": return .us5 + case "eu1": return .eu1 + case "ap1": return .ap1 + case "ap2": return .ap2 + case "us1_fed": return .us1_fed + default: return .us1 + } + } + + static func mapTrackingConsent(_ consent: String) -> TrackingConsent { + switch consent.lowercased() { + case "granted": return .granted + case "not_granted": return .notGranted + default: return .pending + } + } + + static func mapVerbosity(_ verbosity: String) -> CoreLoggerLevel { + switch verbosity.lowercased() { + case "debug": return .debug + case "info": return .debug // iOS has no info level, use debug + case "warn": return .warn + case "error": return .error + default: return .error + } + } + + static func mapBatchSize(_ batchSize: String) -> Datadog.Configuration.BatchSize { + switch batchSize.lowercased() { + case "small": return .small + case "large": return .large + default: return .medium + } + } + + static func mapUploadFrequency(_ uploadFrequency: String) -> Datadog.Configuration.UploadFrequency { + switch uploadFrequency.lowercased() { + case "frequent": return .frequent + case "rare": return .rare + default: return .average + } + } + + static func mapBatchProcessingLevel(_ level: String) -> Datadog.Configuration.BatchProcessingLevel { + switch level.lowercased() { + case "low": return .low + case "high": return .high + default: return .medium + } + } + + // MARK: - Initialization + + /// Initialize the Datadog SDK with configuration /// - Parameters: /// - clientToken: Datadog client token for your application /// - environment: Environment name (e.g., "prod", "staging") - /// - service: Service name for your application + /// - service: Service name for your application (nullable) /// - site: Datadog site (e.g., "us1", "eu1", "us3", "us5", "ap1", "us1_fed") + /// - verbosity: SDK verbosity level ("debug", "info", "warn", "error") + /// - trackingConsent: Initial tracking consent ("granted", "not_granted", "pending") + /// - batchSize: Batch size for data uploads + /// - uploadFrequency: Upload frequency for data batches + /// - batchProcessingLevel: Batch processing level + /// - additionalConfiguration: Additional configuration dictionary (includes _dd.version, _dd.version_suffix, etc.) /// - Returns: Boolean indicating successful initialization @objc public static func initialize( clientToken: String, environment: String, - service: String, + service: String?, site: String, - verbosity: String + verbosity: String, + trackingConsent: String, + batchSize: String?, + uploadFrequency: String?, + batchProcessingLevel: String?, + additionalConfiguration: NSDictionary? ) -> Bool { - // Store service name for feature modules - currentServiceName = service - - let configuration = DatadogCore.Datadog.Configuration( + var configuration = DatadogCore.Datadog.Configuration( clientToken: clientToken, env: environment, - site: { () in - switch site.lowercased() { - case "us3": return .us3 - case "us5": return .us5 - case "eu1": return .eu1 - case "ap1": return .ap1 - case "us1_fed": return .us1_fed - default: return .us1 - } - }() + site: mapSite(site), + service: service ) - // Set SDK verbosity level - switch verbosity.lowercased() { - case "debug": DatadogCore.Datadog.verbosityLevel = .debug - case "info": DatadogCore.Datadog.verbosityLevel = .debug // iOS has no info level, use debug - case "warn": DatadogCore.Datadog.verbosityLevel = .warn - case "error": DatadogCore.Datadog.verbosityLevel = .error - default: DatadogCore.Datadog.verbosityLevel = .error + if let batchSize = batchSize { + configuration.batchSize = mapBatchSize(batchSize) + } + + if let uploadFrequency = uploadFrequency { + configuration.uploadFrequency = mapUploadFrequency(uploadFrequency) + } + + if let batchProcessingLevel = batchProcessingLevel { + configuration.batchProcessingLevel = mapBatchProcessingLevel(batchProcessingLevel) + } + + DatadogCore.Datadog.verbosityLevel = mapVerbosity(verbosity) + + // Apply additional configuration + if let additionalConfig = additionalConfiguration as? [String: Any] { + configuration._internal_mutation { + $0.additionalConfiguration = additionalConfig + } } // Initialize Datadog SDK - DatadogCore.Datadog.initialize( + let core = DatadogCore.Datadog.initialize( with: configuration, - trackingConsent: .granted + trackingConsent: mapTrackingConsent(trackingConsent) ) - return true + return !(core is DatadogInternal.NOPDatadogCore) } - /// Get the current service name (for use by feature modules) - @objc public static func getServiceName() -> String { - return currentServiceName - } } diff --git a/native-wrappers/ios/DatadogWrapper/Sources/DatadogWrapper/DdLogs.swift b/native-wrappers/ios/DatadogWrapper/Sources/DatadogWrapper/DdLogs.swift index c8a15cc..1654bb1 100644 --- a/native-wrappers/ios/DatadogWrapper/Sources/DatadogWrapper/DdLogs.swift +++ b/native-wrappers/ios/DatadogWrapper/Sources/DatadogWrapper/DdLogs.swift @@ -13,13 +13,7 @@ public class DdLogs: NSObject { // Enable Logs feature (dd-sdk-ios v3.x API) Logs.enable() - // Create logger with service name from global config - let serviceName = DatadogWrapper.getServiceName() - logger = Logger.create( - with: Logger.Configuration( - service: serviceName - ) - ) + logger = Logger.create(with: Logger.Configuration()) } /// Log a debug message diff --git a/native-wrappers/ios/DatadogWrapper/Tests/DatadogWrapperTests/DatadogWrapperTests.swift b/native-wrappers/ios/DatadogWrapper/Tests/DatadogWrapperTests/DatadogWrapperTests.swift new file mode 100644 index 0000000..905b649 --- /dev/null +++ b/native-wrappers/ios/DatadogWrapper/Tests/DatadogWrapperTests/DatadogWrapperTests.swift @@ -0,0 +1,199 @@ +import XCTest +import DatadogCore +import DatadogInternal +@testable import DatadogWrapper + +final class DatadogWrapperTests: XCTestCase { + + // MARK: - Site mapping + + func test_mapSite_us1() { + XCTAssertEqual(DdSdkNativeWrapper.mapSite("us1"), .us1) + } + + func test_mapSite_us3() { + XCTAssertEqual(DdSdkNativeWrapper.mapSite("us3"), .us3) + } + + func test_mapSite_us5() { + XCTAssertEqual(DdSdkNativeWrapper.mapSite("us5"), .us5) + } + + func test_mapSite_eu1() { + XCTAssertEqual(DdSdkNativeWrapper.mapSite("eu1"), .eu1) + } + + func test_mapSite_ap1() { + XCTAssertEqual(DdSdkNativeWrapper.mapSite("ap1"), .ap1) + } + + func test_mapSite_ap2() { + XCTAssertEqual(DdSdkNativeWrapper.mapSite("ap2"), .ap2) + } + + func test_mapSite_us1Fed() { + XCTAssertEqual(DdSdkNativeWrapper.mapSite("us1_fed"), .us1_fed) + } + + func test_mapSite_unknownDefaultsToUs1() { + XCTAssertEqual(DdSdkNativeWrapper.mapSite("invalid"), .us1) + } + + func test_mapSite_isCaseInsensitive() { + XCTAssertEqual(DdSdkNativeWrapper.mapSite("EU1"), .eu1) + } + + // MARK: - Tracking consent mapping + + func test_mapTrackingConsent_granted() { + XCTAssertEqual(DdSdkNativeWrapper.mapTrackingConsent("granted"), .granted) + } + + func test_mapTrackingConsent_notGranted() { + XCTAssertEqual(DdSdkNativeWrapper.mapTrackingConsent("not_granted"), .notGranted) + } + + func test_mapTrackingConsent_pending() { + XCTAssertEqual(DdSdkNativeWrapper.mapTrackingConsent("pending"), .pending) + } + + func test_mapTrackingConsent_unknownDefaultsToPending() { + XCTAssertEqual(DdSdkNativeWrapper.mapTrackingConsent("invalid"), .pending) + } + + // MARK: - Verbosity mapping + + func test_mapVerbosity_debug() { + XCTAssertEqual(DdSdkNativeWrapper.mapVerbosity("debug"), .debug) + } + + func test_mapVerbosity_infoMapsToDebug() { + XCTAssertEqual(DdSdkNativeWrapper.mapVerbosity("info"), .debug) + } + + func test_mapVerbosity_warn() { + XCTAssertEqual(DdSdkNativeWrapper.mapVerbosity("warn"), .warn) + } + + func test_mapVerbosity_error() { + XCTAssertEqual(DdSdkNativeWrapper.mapVerbosity("error"), .error) + } + + func test_mapVerbosity_unknownDefaultsToError() { + XCTAssertEqual(DdSdkNativeWrapper.mapVerbosity("invalid"), .error) + } + + // MARK: - Batch size mapping + + func test_mapBatchSize_small() { + XCTAssertEqual(DdSdkNativeWrapper.mapBatchSize("small"), .small) + } + + func test_mapBatchSize_medium() { + XCTAssertEqual(DdSdkNativeWrapper.mapBatchSize("medium"), .medium) + } + + func test_mapBatchSize_large() { + XCTAssertEqual(DdSdkNativeWrapper.mapBatchSize("large"), .large) + } + + func test_mapBatchSize_unknownDefaultsToMedium() { + XCTAssertEqual(DdSdkNativeWrapper.mapBatchSize("invalid"), .medium) + } + + // MARK: - Upload frequency mapping + + func test_mapUploadFrequency_frequent() { + XCTAssertEqual(DdSdkNativeWrapper.mapUploadFrequency("frequent"), .frequent) + } + + func test_mapUploadFrequency_average() { + XCTAssertEqual(DdSdkNativeWrapper.mapUploadFrequency("average"), .average) + } + + func test_mapUploadFrequency_rare() { + XCTAssertEqual(DdSdkNativeWrapper.mapUploadFrequency("rare"), .rare) + } + + func test_mapUploadFrequency_unknownDefaultsToAverage() { + XCTAssertEqual(DdSdkNativeWrapper.mapUploadFrequency("invalid"), .average) + } + + // MARK: - Batch processing level mapping + + func test_mapBatchProcessingLevel_low() { + XCTAssertEqual(DdSdkNativeWrapper.mapBatchProcessingLevel("low"), .low) + } + + func test_mapBatchProcessingLevel_medium() { + XCTAssertEqual(DdSdkNativeWrapper.mapBatchProcessingLevel("medium"), .medium) + } + + func test_mapBatchProcessingLevel_high() { + XCTAssertEqual(DdSdkNativeWrapper.mapBatchProcessingLevel("high"), .high) + } + + func test_mapBatchProcessingLevel_unknownDefaultsToMedium() { + XCTAssertEqual(DdSdkNativeWrapper.mapBatchProcessingLevel("invalid"), .medium) + } + + // MARK: - SDK initialization + + override func tearDown() { + Datadog.stopInstance() + Datadog.verbosityLevel = nil + super.tearDown() + } + + func test_initialize_initializesTheSdk() { + let result = DdSdkNativeWrapper.initialize( + clientToken: "pub-test-token", + environment: "test", + service: nil, + site: "us1", + verbosity: "error", + trackingConsent: "pending", + batchSize: nil, + uploadFrequency: nil, + batchProcessingLevel: nil, + additionalConfiguration: nil + ) + + XCTAssertTrue(result) + XCTAssertTrue(Datadog.isInitialized()) + } + + func test_initialize_setsVerbosityLevel() { + _ = DdSdkNativeWrapper.initialize( + clientToken: "pub-test-token", + environment: "test", + service: nil, + site: "us1", + verbosity: "warn", + trackingConsent: "pending", + batchSize: nil, + uploadFrequency: nil, + batchProcessingLevel: nil, + additionalConfiguration: nil + ) + + XCTAssertEqual(Datadog.verbosityLevel, .warn) + } + + func test_initialize_withDebugVerbosity_setsDebugLevel() { + _ = DdSdkNativeWrapper.initialize( + clientToken: "pub-test-token", + environment: "test", + service: nil, + site: "us1", + verbosity: "debug", + trackingConsent: "pending", + batchSize: nil, + uploadFrequency: nil, + batchProcessingLevel: nil, + additionalConfiguration: nil + ) + + XCTAssertEqual(Datadog.verbosityLevel, .debug) + } +} diff --git a/tests/DatadogSdk.Maui.Tests/DatadogSdk.Maui.Tests.csproj b/tests/DatadogSdk.Maui.Tests/DatadogSdk.Maui.Tests.csproj new file mode 100644 index 0000000..3623ad2 --- /dev/null +++ b/tests/DatadogSdk.Maui.Tests/DatadogSdk.Maui.Tests.csproj @@ -0,0 +1,33 @@ + + + net10.0 + DatadogSdk.Maui.Tests + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/DatadogSdk.Maui.Tests/DdSdkConfigurationTests.cs b/tests/DatadogSdk.Maui.Tests/DdSdkConfigurationTests.cs new file mode 100644 index 0000000..420e81d --- /dev/null +++ b/tests/DatadogSdk.Maui.Tests/DdSdkConfigurationTests.cs @@ -0,0 +1,268 @@ +using DatadogSdk.Maui.Configuration; +using Xunit; + +namespace DatadogSdk.Maui.Tests; + +public class DdSdkConfigurationTests : IDisposable +{ + private readonly MockNativeSdkBridge bridge = new(); + + public DdSdkConfigurationTests() + { + DdSdk.testBridge = bridge; + } + + public void Dispose() + { + DdSdk.testBridge = null; + InternalLog.Verbosity = null; + GC.SuppressFinalize(this); + } + + // --- Full config --------------------------------------------------------- + + [Fact] + public void Initialize_PassesAllConfigToNative() + { + DdSdkConfiguration config = new() + { + ClientToken = "pub-token-123", + Environment = "staging", + Service = "my-service", + Site = DatadogSite.Eu1, + TrackingConsent = TrackingConsent.Pending, + Verbosity = SdkVerbosity.DEBUG, + BatchSize = BatchSize.Large, + UploadFrequency = UploadFrequency.Frequent, + BatchProcessingLevel = BatchProcessingLevel.High, + AdditionalConfiguration = new Dictionary { { "_dd.needsClearTextHttp", true } } + }; + + bool result = DdSdk.Initialize(config); + + Assert.True(result); + Assert.Equal(1, bridge.CallCount); + Assert.Equal("pub-token-123", bridge.ClientToken); + Assert.Equal("staging", bridge.Environment); + Assert.Equal("my-service", bridge.Service); + Assert.Equal("eu1", bridge.Site); + Assert.Equal("pending", bridge.TrackingConsent); + Assert.Equal("debug", bridge.Verbosity); + Assert.Equal("large", bridge.BatchSize); + Assert.Equal("frequent", bridge.UploadFrequency); + Assert.Equal("high", bridge.BatchProcessingLevel); + Assert.NotNull(bridge.AdditionalConfiguration); + Assert.Equal(true, bridge.AdditionalConfiguration["_dd.needsClearTextHttp"]); + } + + // --- Native failure ------------------------------------------------------ + + [Fact] + public void Initialize_ReturnsFalseWhenNativeFails() + { + bridge.ReturnValue = false; + + bool result = DdSdk.Initialize(new DdSdkConfiguration + { + ClientToken = "pub-token", + Environment = "test" + }); + + Assert.False(result); + } + + // --- JSON-based config --------------------------------------------------- + + [Fact] + public void Initialize_WithConfigFromJson_PassesAllValuesToNative() + { + string json = File.ReadAllText(Path.Combine("Fixtures", "full_config.json")); + DdSdkConfiguration config = FileBasedConfiguration.ParseJsonConfig(json); + + bool result = DdSdk.Initialize(config); + + Assert.True(result); + Assert.Equal("pub-test-full-config", bridge.ClientToken); + Assert.Equal("staging", bridge.Environment); + Assert.Equal("my-maui-app", bridge.Service); + Assert.Equal("eu1", bridge.Site); + Assert.Equal("pending", bridge.TrackingConsent); + Assert.Equal("debug", bridge.Verbosity); + Assert.Equal("large", bridge.BatchSize); + Assert.Equal("frequent", bridge.UploadFrequency); + Assert.Equal("high", bridge.BatchProcessingLevel); + Assert.NotNull(bridge.AdditionalConfiguration); + Assert.Equal("2.1.0", bridge.AdditionalConfiguration["_dd.version"]); + Assert.Equal("-rc1", bridge.AdditionalConfiguration["_dd.version_suffix"]); + Assert.Equal(true, bridge.AdditionalConfiguration["_dd.needsClearTextHttp"]); + Assert.Equal("custom_value", bridge.AdditionalConfiguration["_dd.custom_key"]); + } + + // --- Site ---------------------------------------------------------------- + + [Theory] + [InlineData(DatadogSite.Us1, "us1")] + [InlineData(DatadogSite.Us3, "us3")] + [InlineData(DatadogSite.Us5, "us5")] + [InlineData(DatadogSite.Eu1, "eu1")] + [InlineData(DatadogSite.Ap1, "ap1")] + [InlineData(DatadogSite.Ap2, "ap2")] + [InlineData(DatadogSite.Us1Fed, "us1_fed")] + public void Initialize_WithSite_PassesConvertedValueToNative(DatadogSite site, string expected) + { + DdSdk.Initialize(new DdSdkConfiguration + { + ClientToken = "pub-token", + Environment = "test", + Site = site + }); + + Assert.Equal(expected, bridge.Site); + } + + // --- Tracking consent ---------------------------------------------------- + + [Theory] + [InlineData(TrackingConsent.Granted, "granted")] + [InlineData(TrackingConsent.NotGranted, "not_granted")] + [InlineData(TrackingConsent.Pending, "pending")] + public void Initialize_WithExplicitTrackingConsent(TrackingConsent consent, string expected) + { + DdSdk.Initialize(new DdSdkConfiguration + { + ClientToken = "pub-token", + Environment = "test", + TrackingConsent = consent + }); + + Assert.Equal(expected, bridge.TrackingConsent); + } + + // --- Multiple initializations -------------------------------------------- + + [Fact] + public void Initialize_CalledMultipleTimes() + { + DdSdkConfiguration config = new() + { + ClientToken = "pub-token", + Environment = "test" + }; + + bool result = DdSdk.Initialize(config); + Assert.True(result); + bridge.ReturnValue = false; + result = DdSdk.Initialize(config); + Assert.False(result); + result = DdSdk.Initialize(config); + Assert.False(result); + } + + // --- Version ------------------------------------------------------------- + + [Fact] + public void Initialize_WithVersion_AddsVersionToAdditionalConfig() + { + DdSdk.Initialize(new DdSdkConfiguration + { + ClientToken = "pub-token", + Environment = "test", + Version = "3.0.0" + }); + + Assert.NotNull(bridge.AdditionalConfiguration); + Assert.Equal("3.0.0", bridge.AdditionalConfiguration["_dd.version"]); + } + + // --- Version suffix ------------------------------------------------------ + + [Fact] + public void Initialize_WithVersionSuffix_AddsSuffixToAdditionalConfig() + { + DdSdk.Initialize(new DdSdkConfiguration + { + ClientToken = "pub-token", + Environment = "test", + VersionSuffix = "-beta" + }); + + Assert.NotNull(bridge.AdditionalConfiguration); + Assert.Equal("-beta", bridge.AdditionalConfiguration["_dd.version_suffix"]); + } + + // --- Version and suffix -------------------------------------------------- + + [Fact] + public void Initialize_WithVersionAndSuffix_AddsBothToAdditionalConfig() + { + DdSdk.Initialize(new DdSdkConfiguration + { + ClientToken = "pub-token", + Environment = "test", + Version = "2.0.0", + VersionSuffix = "-rc1" + }); + + Assert.NotNull(bridge.AdditionalConfiguration); + Assert.Equal("2.0.0", bridge.AdditionalConfiguration["_dd.version"]); + Assert.Equal("-rc1", bridge.AdditionalConfiguration["_dd.version_suffix"]); + } + + // --- Service name -------------------------------------------------------- + + [Fact] + public void Initialize_WithServiceName_PassesServiceToNative() + { + DdSdk.Initialize(new DdSdkConfiguration + { + ClientToken = "pub-token", + Environment = "test", + Service = "custom-service" + }); + + Assert.Equal("custom-service", bridge.Service); + } + + [Fact] + public void Initialize_WithoutServiceName_PassesNullToNative() + { + DdSdk.Initialize(new DdSdkConfiguration + { + ClientToken = "pub-token", + Environment = "test" + }); + + Assert.Null(bridge.Service); + } + + // --- Verbosity ----------------------------------------------------------- + + [Theory] + [InlineData(SdkVerbosity.DEBUG, "debug")] + [InlineData(SdkVerbosity.INFO, "info")] + [InlineData(SdkVerbosity.WARN, "warn")] + [InlineData(SdkVerbosity.ERROR, "error")] + public void Initialize_WithVerbosity_PassesLowercaseToNative(SdkVerbosity verbosity, string expected) + { + DdSdk.Initialize(new DdSdkConfiguration + { + ClientToken = "pub-token", + Environment = "test", + Verbosity = verbosity + }); + + Assert.Equal(expected, bridge.Verbosity); + } + + [Fact] + public void Initialize_WithoutVerbosity_DefaultsToError() + { + DdSdk.Initialize(new DdSdkConfiguration + { + ClientToken = "pub-token", + Environment = "test" + }); + + Assert.Equal("error", bridge.Verbosity); + } +} diff --git a/tests/DatadogSdk.Maui.Tests/DdSdkConversionTests.cs b/tests/DatadogSdk.Maui.Tests/DdSdkConversionTests.cs new file mode 100644 index 0000000..636f27a --- /dev/null +++ b/tests/DatadogSdk.Maui.Tests/DdSdkConversionTests.cs @@ -0,0 +1,94 @@ +using DatadogSdk.Maui.Configuration; +using Xunit; + +namespace DatadogSdk.Maui.Tests; + +public class DdSdkConversionTests +{ + // --- DatadogSite conversion --- + [Theory] + [InlineData(DatadogSite.Us1, "us1")] + [InlineData(DatadogSite.Us3, "us3")] + [InlineData(DatadogSite.Us5, "us5")] + [InlineData(DatadogSite.Eu1, "eu1")] + [InlineData(DatadogSite.Ap1, "ap1")] + [InlineData(DatadogSite.Ap2, "ap2")] + [InlineData(DatadogSite.Us1Fed, "us1_fed")] + public void ConvertSite_ReturnsCorrectString(DatadogSite site, string expected) + { + Assert.Equal(expected, DdSdk.ConvertSite(site)); + } + + // --- TrackingConsent conversion --- + [Theory] + [InlineData(TrackingConsent.Granted, "granted")] + [InlineData(TrackingConsent.NotGranted, "not_granted")] + [InlineData(TrackingConsent.Pending, "pending")] + public void ConvertTrackingConsent_ReturnsCorrectString(TrackingConsent consent, string expected) + { + Assert.Equal(expected, DdSdk.ConvertTrackingConsent(consent)); + } + + // --- BuildAdditionalConfiguration --- + + [Fact] + public void BuildAdditionalConfiguration_AllNull_ReturnsNull() + { + Dictionary? result = DdSdk.BuildAdditionalConfiguration(null, null, null); + Assert.Null(result); + } + + [Fact] + public void BuildAdditionalConfiguration_VersionOnly_SetsVersionKey() + { + Dictionary? result = DdSdk.BuildAdditionalConfiguration(null, "1.2.3", null); + Assert.NotNull(result); + Assert.Equal("1.2.3", result["_dd.version"]); + Assert.False(result.ContainsKey("_dd.version_suffix")); + } + + [Fact] + public void BuildAdditionalConfiguration_VersionSuffixOnly_SetsSuffixKey() + { + Dictionary? result = DdSdk.BuildAdditionalConfiguration(null, null, "-beta"); + Assert.NotNull(result); + Assert.Equal("-beta", result["_dd.version_suffix"]); + Assert.False(result.ContainsKey("_dd.version")); + } + + [Fact] + public void BuildAdditionalConfiguration_VersionAndSuffix_SetsBothKeys() + { + Dictionary? result = DdSdk.BuildAdditionalConfiguration(null, "2.0.0", "-rc1"); + Assert.NotNull(result); + Assert.Equal("2.0.0", result["_dd.version"]); + Assert.Equal("-rc1", result["_dd.version_suffix"]); + } + + [Fact] + public void BuildAdditionalConfiguration_ExistingConfigPlusVersion_MergesWithoutLoss() + { + Dictionary extra = new() { { "custom_key", "custom_value" } }; + Dictionary? result = DdSdk.BuildAdditionalConfiguration(extra, "3.0.0", null); + Assert.NotNull(result); + Assert.Equal("3.0.0", result["_dd.version"]); + Assert.Equal("custom_value", result["custom_key"]); + } + + [Fact] + public void BuildAdditionalConfiguration_DoesNotMutateSourceDictionary() + { + Dictionary original = new() { { "key", "value" } }; + DdSdk.BuildAdditionalConfiguration(original, "1.0.0", null); + Assert.False(original.ContainsKey("_dd.version")); + } + + [Fact] + public void BuildAdditionalConfiguration_InternalKeyPassedViaAdditionalConfig_FlowsThrough() + { + Dictionary extra = new() { { "_dd.needsClearTextHttp", true } }; + Dictionary? result = DdSdk.BuildAdditionalConfiguration(extra, null, null); + Assert.NotNull(result); + Assert.Equal(true, result["_dd.needsClearTextHttp"]); + } +} diff --git a/tests/DatadogSdk.Maui.Tests/FileBasedConfigurationTests.cs b/tests/DatadogSdk.Maui.Tests/FileBasedConfigurationTests.cs new file mode 100644 index 0000000..d48e128 --- /dev/null +++ b/tests/DatadogSdk.Maui.Tests/FileBasedConfigurationTests.cs @@ -0,0 +1,144 @@ +using System.Text.Json; +using DatadogSdk.Maui.Configuration; +using Xunit; + +namespace DatadogSdk.Maui.Tests; + +public class FileBasedConfigurationTests +{ + private static string LoadFixture(string filename) + { + string path = Path.Combine("Fixtures", filename); + return File.ReadAllText(path); + } + + // --- Full config --------------------------------------------------------- + + [Fact] + public void ParseJsonConfig_FullConfig_MapsAllFields() + { + string json = LoadFixture("full_config.json"); + DdSdkConfiguration config = FileBasedConfiguration.ParseJsonConfig(json); + + Assert.Equal("pub-test-full-config", config.ClientToken); + Assert.Equal("staging", config.Environment); + Assert.Equal(DatadogSite.Eu1, config.Site); + Assert.Equal("my-maui-app", config.Service); + Assert.Equal(TrackingConsent.Pending, config.TrackingConsent); + Assert.Equal(SdkVerbosity.DEBUG, config.Verbosity); + Assert.Equal(BatchSize.Large, config.BatchSize); + Assert.Equal(UploadFrequency.Frequent, config.UploadFrequency); + Assert.Equal(BatchProcessingLevel.High, config.BatchProcessingLevel); + Assert.Equal("2.1.0", config.Version); + Assert.Equal("-rc1", config.VersionSuffix); + } + + [Fact] + public void ParseJsonConfig_FullConfig_MapsAdditionalConfiguration() + { + string json = LoadFixture("full_config.json"); + DdSdkConfiguration config = FileBasedConfiguration.ParseJsonConfig(json); + + Assert.NotNull(config.AdditionalConfiguration); + Assert.Equal(true, config.AdditionalConfiguration["_dd.needsClearTextHttp"]); + Assert.Equal("custom_value", config.AdditionalConfiguration["_dd.custom_key"]); + } + + // --- Minimal config ------------------------------------------------------ + + [Fact] + public void ParseJsonConfig_MinimalConfig_SetsRequiredFields() + { + string json = LoadFixture("minimal_config.json"); + DdSdkConfiguration config = FileBasedConfiguration.ParseJsonConfig(json); + + Assert.Equal("pub-test-minimal", config.ClientToken); + Assert.Equal("prod", config.Environment); + } + + [Fact] + public void ParseJsonConfig_MinimalConfig_UsesDefaults() + { + string json = LoadFixture("minimal_config.json"); + DdSdkConfiguration config = FileBasedConfiguration.ParseJsonConfig(json); + + Assert.Equal(DatadogSite.Us1, config.Site); + Assert.Equal(TrackingConsent.Granted, config.TrackingConsent); + Assert.Null(config.Service); + Assert.Null(config.Verbosity); + Assert.Null(config.BatchSize); + Assert.Null(config.UploadFrequency); + Assert.Null(config.BatchProcessingLevel); + Assert.Null(config.Version); + Assert.Null(config.VersionSuffix); + Assert.Null(config.AdditionalConfiguration); + } + + // --- Malformed JSON ------------------------------------------------------ + + [Fact] + public void ParseJsonConfig_MalformedJson_ThrowsJsonException() + { + string json = LoadFixture("malformed_config.json"); + Assert.ThrowsAny(() => FileBasedConfiguration.ParseJsonConfig(json)); + } + + // --- Missing required fields --------------------------------------------- + + [Fact] + public void ParseJsonConfig_MissingClientToken_ThrowsArgumentException() + { + string json = """{ "Environment": "prod" }"""; + ArgumentException ex = Assert.Throws( + () => FileBasedConfiguration.ParseJsonConfig(json)); + Assert.Contains("ClientToken", ex.Message); + } + + [Fact] + public void ParseJsonConfig_MissingEnvironment_ThrowsArgumentException() + { + string json = """{ "ClientToken": "pub-xxx" }"""; + ArgumentException ex = Assert.Throws( + () => FileBasedConfiguration.ParseJsonConfig(json)); + Assert.Contains("Environment", ex.Message); + } + + [Fact] + public void ParseJsonConfig_EmptyClientToken_ThrowsArgumentException() + { + string json = """{ "ClientToken": "", "Environment": "prod" }"""; + ArgumentException ex = Assert.Throws( + () => FileBasedConfiguration.ParseJsonConfig(json)); + Assert.Contains("ClientToken", ex.Message); + } + + [Fact] + public void ParseJsonConfig_WhitespaceEnvironment_ThrowsArgumentException() + { + string json = """{ "ClientToken": "pub-xxx", "Environment": " " }"""; + ArgumentException ex = Assert.Throws( + () => FileBasedConfiguration.ParseJsonConfig(json)); + Assert.Contains("Environment", ex.Message); + } + + // --- Invalid enum values ------------------------------------------------- + + [Fact] + public void ParseJsonConfig_InvalidSite_ThrowsArgumentException() + { + string json = """{ "ClientToken": "pub-xxx", "Environment": "prod", "Site": "InvalidSite" }"""; + ArgumentException ex = Assert.Throws( + () => FileBasedConfiguration.ParseJsonConfig(json)); + Assert.Contains("Site", ex.Message); + } + + [Fact] + public void ParseJsonConfig_InvalidVerbosity_ThrowsArgumentException() + { + string json = """{ "ClientToken": "pub-xxx", "Environment": "prod", "Verbosity": "LOUD" }"""; + ArgumentException ex = Assert.Throws( + () => FileBasedConfiguration.ParseJsonConfig(json)); + Assert.Contains("Verbosity", ex.Message); + } + +} diff --git a/tests/DatadogSdk.Maui.Tests/Fixtures/full_config.json b/tests/DatadogSdk.Maui.Tests/Fixtures/full_config.json new file mode 100644 index 0000000..a891fa4 --- /dev/null +++ b/tests/DatadogSdk.Maui.Tests/Fixtures/full_config.json @@ -0,0 +1,17 @@ +{ + "ClientToken": "pub-test-full-config", + "Environment": "staging", + "Site": "Eu1", + "Service": "my-maui-app", + "TrackingConsent": "Pending", + "Verbosity": "DEBUG", + "BatchSize": "Large", + "UploadFrequency": "Frequent", + "BatchProcessingLevel": "High", + "Version": "2.1.0", + "VersionSuffix": "-rc1", + "AdditionalConfiguration": { + "_dd.needsClearTextHttp": true, + "_dd.custom_key": "custom_value" + } +} diff --git a/tests/DatadogSdk.Maui.Tests/Fixtures/malformed_config.json b/tests/DatadogSdk.Maui.Tests/Fixtures/malformed_config.json new file mode 100644 index 0000000..33eb24d --- /dev/null +++ b/tests/DatadogSdk.Maui.Tests/Fixtures/malformed_config.json @@ -0,0 +1 @@ +{ "ClientToken": "pub-test", "Environment": INVALID_JSON } \ No newline at end of file diff --git a/tests/DatadogSdk.Maui.Tests/Fixtures/minimal_config.json b/tests/DatadogSdk.Maui.Tests/Fixtures/minimal_config.json new file mode 100644 index 0000000..d98f327 --- /dev/null +++ b/tests/DatadogSdk.Maui.Tests/Fixtures/minimal_config.json @@ -0,0 +1,4 @@ +{ + "ClientToken": "pub-test-minimal", + "Environment": "prod" +} diff --git a/tests/DatadogSdk.Maui.Tests/InternalLogTests.cs b/tests/DatadogSdk.Maui.Tests/InternalLogTests.cs new file mode 100644 index 0000000..abeec24 --- /dev/null +++ b/tests/DatadogSdk.Maui.Tests/InternalLogTests.cs @@ -0,0 +1,65 @@ +using System.IO; +using Xunit; + +namespace DatadogSdk.Maui.Tests; + +public class InternalLogTests : IDisposable +{ + private readonly StringWriter _consoleOutput; + private readonly TextWriter _originalOutput; + + public InternalLogTests() + { + _originalOutput = Console.Out; + _consoleOutput = new StringWriter(); + Console.SetOut(_consoleOutput); + } + + public void Dispose() + { + Console.SetOut(_originalOutput); + _consoleOutput.Dispose(); + InternalLog.Verbosity = null; + } + + [Fact] + public void Log_WhenVerbosityNull_DoesNotOutput() + { + InternalLog.Verbosity = null; + InternalLog.Log("test message", SdkVerbosity.ERROR); + Assert.Empty(_consoleOutput.ToString()); + } + + [Fact] + public void Log_WhenLevelMeetsVerbosity_Outputs() + { + InternalLog.Verbosity = SdkVerbosity.DEBUG; + InternalLog.Log("hello", SdkVerbosity.DEBUG); + Assert.Contains("DATADOG: [DEBUG] hello", _consoleOutput.ToString()); + } + + [Fact] + public void Log_WhenLevelBelowVerbosity_DoesNotOutput() + { + InternalLog.Verbosity = SdkVerbosity.ERROR; + InternalLog.Log("should not appear", SdkVerbosity.DEBUG); + Assert.Empty(_consoleOutput.ToString()); + } + + [Fact] + public void Log_HigherLevelAlwaysPassesLowerVerbosity() + { + InternalLog.Verbosity = SdkVerbosity.DEBUG; + InternalLog.Log("error msg", SdkVerbosity.ERROR); + Assert.Contains("DATADOG: [ERROR] error msg", _consoleOutput.ToString()); + } + + [Fact] + public void Log_FormatIncludesPrefix() + { + InternalLog.Verbosity = SdkVerbosity.WARN; + InternalLog.Log("test", SdkVerbosity.WARN); + var output = _consoleOutput.ToString(); + Assert.StartsWith("DATADOG:", output.Trim()); + } +} diff --git a/tests/DatadogSdk.Maui.Tests/MockNativeSdkBridge.cs b/tests/DatadogSdk.Maui.Tests/MockNativeSdkBridge.cs new file mode 100644 index 0000000..734c73b --- /dev/null +++ b/tests/DatadogSdk.Maui.Tests/MockNativeSdkBridge.cs @@ -0,0 +1,37 @@ +namespace DatadogSdk.Maui.Tests; + +internal class MockNativeSdkBridge : DdSdk.INativeBridge +{ + public int CallCount { get; private set; } + public string? ClientToken { get; private set; } + public string? Environment { get; private set; } + public string? Service { get; private set; } + public string? Site { get; private set; } + public string? Verbosity { get; private set; } + public string? TrackingConsent { get; private set; } + public string? BatchSize { get; private set; } + public string? UploadFrequency { get; private set; } + public string? BatchProcessingLevel { get; private set; } + public Dictionary? AdditionalConfiguration { get; private set; } + public bool ReturnValue { get; set; } = true; + + public bool Initialize( + string clientToken, string environment, string? service, + string site, string verbosity, string trackingConsent, + string? batchSize, string? uploadFrequency, string? batchProcessingLevel, + Dictionary? additionalConfiguration) + { + CallCount++; + ClientToken = clientToken; + Environment = environment; + Service = service; + Site = site; + Verbosity = verbosity; + TrackingConsent = trackingConsent; + BatchSize = batchSize; + UploadFrequency = uploadFrequency; + BatchProcessingLevel = batchProcessingLevel; + AdditionalConfiguration = additionalConfiguration; + return ReturnValue; + } +}