diff --git a/CHANGELOG.md b/CHANGELOG.md index 418e317c8..70fdddc34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [14.0.0](https://github.com/Instabug/Instabug-Flutter/compare/v13.4.0...v14.0.0) (November 18, 2024) + +### Added + +- Add support for tracing network requests from Instabug to services like Datadog and New Relic ([#481](https://github.com/Instabug/Instabug-Flutter/pull/481)). + +### Changed + +- Bump Instabug Android SDK to v14.0.0 ([#532](https://github.com/Instabug/Instabug-Flutter/pull/532)). [See release notes](https://github.com/Instabug/Instabug-Android/releases/tag/v14.0.0). +- Bump Instabug iOS SDK to v14.0.0 ([#532](https://github.com/Instabug/Instabug-Flutter/pull/532)). [See release notes](https://github.com/Instabug/Instabug-iOS/releases/tag/14.0.0), + ## [13.4.0](https://github.com/Instabug/Instabug-Flutter/compare/v13.3.0...v13.4.0) (September 29, 2024) ### Added diff --git a/android/build.gradle b/android/build.gradle index 92654b91b..ebaccfa51 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,5 +1,5 @@ group 'com.instabug.flutter' -version '13.4.0' +version '14.0.0' buildscript { repositories { @@ -22,7 +22,10 @@ rootProject.allprojects { apply plugin: 'com.android.library' android { - compileSdkVersion 28 + if (project.android.hasProperty("namespace")) { + namespace "com.instabug.flutter" + } + compileSdkVersion 33 compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 @@ -41,10 +44,11 @@ android { } dependencies { - api 'com.instabug.library:instabug:13.4.1' - + api 'com.instabug.library:instabug:14.0.0' testImplementation 'junit:junit:4.13.2' testImplementation "org.mockito:mockito-inline:3.12.1" + testImplementation "io.mockk:mockk:1.13.13" + } // add upload_symbols task diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 8c678b4e2..c6dec7d9d 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,5 +1,4 @@ - + data) { if (data.containsKey("serverErrorMessage")) { serverErrorMessage = (String) data.get("serverErrorMessage"); } + Boolean isW3cHeaderFound = null; + Number partialId = null; + Number networkStartTimeInSeconds = null; + String w3CGeneratedHeader = null; + String w3CCaughtHeader = null; + + if (data.containsKey("isW3cHeaderFound")) { + isW3cHeaderFound = (Boolean) data.get("isW3cHeaderFound"); + } + + if (data.containsKey("partialId")) { + + + partialId = ((Number) data.get("partialId")); - Method method = Reflection.getMethod(Class.forName("com.instabug.apm.networking.APMNetworkLogger"), "log", long.class, long.class, String.class, String.class, long.class, String.class, String.class, String.class, String.class, String.class, long.class, int.class, String.class, String.class, String.class, String.class, APMCPNetworkLog.W3CExternalTraceAttributes.class); - if (method != null) { - method.invoke(apmNetworkLogger, requestStartTime, requestDuration, requestHeaders, requestBody, requestBodySize, requestMethod, requestUrl, requestContentType, responseHeaders, responseBody, responseBodySize, statusCode, responseContentType, errorMessage, gqlQueryName, serverErrorMessage, null); - } else { - Log.e(TAG, "APMNetworkLogger.log was not found by reflection"); + } + if (data.containsKey("networkStartTimeInSeconds")) { + networkStartTimeInSeconds = ((Number) data.get("networkStartTimeInSeconds")); } - } catch (Exception e) { - e.printStackTrace(); + if (data.containsKey("w3CGeneratedHeader")) { + + w3CGeneratedHeader = (String) data.get("w3CGeneratedHeader"); + + + } + if (data.containsKey("w3CCaughtHeader")) { + w3CCaughtHeader = (String) data.get("w3CCaughtHeader"); + + } + + + APMCPNetworkLog.W3CExternalTraceAttributes w3cExternalTraceAttributes = + null; + if (isW3cHeaderFound != null) { + w3cExternalTraceAttributes = new APMCPNetworkLog.W3CExternalTraceAttributes( + isW3cHeaderFound, partialId == null ? null : partialId.longValue(), + networkStartTimeInSeconds == null ? null : networkStartTimeInSeconds.longValue(), + w3CGeneratedHeader, w3CCaughtHeader + + ); + } + + Method method = Reflection.getMethod(Class.forName("com.instabug.apm.networking.APMNetworkLogger"), "log", long.class, long.class, String.class, String.class, long.class, String.class, String.class, String.class, String.class, String.class, long.class, int.class, String.class, String.class, String.class, String.class, APMCPNetworkLog.W3CExternalTraceAttributes.class); + if (method != null) { + method.invoke(apmNetworkLogger, requestStartTime, requestDuration, requestHeaders, requestBody, requestBodySize, requestMethod, requestUrl, requestContentType, responseHeaders, responseBody, responseBodySize, statusCode, responseContentType, errorMessage, gqlQueryName, serverErrorMessage, w3cExternalTraceAttributes); + } else { + Log.e(TAG, "APMNetworkLogger.log was not found by reflection"); + } + + } catch(Exception e){ + e.printStackTrace(); + } } - } + + @Override diff --git a/android/src/main/java/com/instabug/flutter/modules/InstabugApi.java b/android/src/main/java/com/instabug/flutter/modules/InstabugApi.java index 74c4e4ba4..7a8549718 100644 --- a/android/src/main/java/com/instabug/flutter/modules/InstabugApi.java +++ b/android/src/main/java/com/instabug/flutter/modules/InstabugApi.java @@ -13,6 +13,10 @@ import com.instabug.flutter.util.ArgsRegistry; import com.instabug.flutter.util.Reflection; import com.instabug.flutter.util.ThreadManager; +import com.instabug.library.internal.crossplatform.CoreFeature; +import com.instabug.library.internal.crossplatform.CoreFeaturesState; +import com.instabug.library.internal.crossplatform.FeaturesStateListener; +import com.instabug.library.internal.crossplatform.InternalCore; import com.instabug.library.Feature; import com.instabug.library.Instabug; import com.instabug.library.InstabugColorTheme; @@ -48,14 +52,19 @@ public class InstabugApi implements InstabugPigeon.InstabugHostApi { private final Callable screenshotProvider; private final InstabugCustomTextPlaceHolder placeHolder = new InstabugCustomTextPlaceHolder(); + private final InstabugPigeon.FeatureFlagsFlutterApi featureFlagsFlutterApi; + public static void init(BinaryMessenger messenger, Context context, Callable screenshotProvider) { - final InstabugApi api = new InstabugApi(context, screenshotProvider); + final InstabugPigeon.FeatureFlagsFlutterApi flutterApi = new InstabugPigeon.FeatureFlagsFlutterApi(messenger); + + final InstabugApi api = new InstabugApi(context, screenshotProvider, flutterApi); InstabugPigeon.InstabugHostApi.setup(messenger, api); } - public InstabugApi(Context context, Callable screenshotProvider) { + public InstabugApi(Context context, Callable screenshotProvider, InstabugPigeon.FeatureFlagsFlutterApi featureFlagsFlutterApi) { this.context = context; this.screenshotProvider = screenshotProvider; + this.featureFlagsFlutterApi = featureFlagsFlutterApi; } @VisibleForTesting @@ -437,6 +446,48 @@ public void networkLog(@NonNull Map data) { } } + @Override + public void registerFeatureFlagChangeListener() { + + try { + InternalCore.INSTANCE._setFeaturesStateListener(new FeaturesStateListener() { + @Override + public void invoke(@NonNull CoreFeaturesState featuresState) { + ThreadManager.runOnMainThread(new Runnable() { + @Override + public void run() { + featureFlagsFlutterApi.onW3CFeatureFlagChange(featuresState.isW3CExternalTraceIdEnabled(), + featuresState.isAttachingGeneratedHeaderEnabled(), + featuresState.isAttachingCapturedHeaderEnabled(), + new InstabugPigeon.FeatureFlagsFlutterApi.Reply() { + @Override + public void reply(Void reply) { + + } + }); + } + }); + } + + }); + } catch (Exception e) { + e.printStackTrace(); + } + + } + + @NonNull + @Override + public Map isW3CFeatureFlagsEnabled() { + Map params = new HashMap(); + params.put("isW3cExternalTraceIDEnabled", InternalCore.INSTANCE._isFeatureEnabled(CoreFeature.W3C_EXTERNAL_TRACE_ID)); + params.put("isW3cExternalGeneratedHeaderEnabled", InternalCore.INSTANCE._isFeatureEnabled(CoreFeature.W3C_ATTACHING_GENERATED_HEADER)); + params.put("isW3cCaughtHeaderEnabled", InternalCore.INSTANCE._isFeatureEnabled(CoreFeature.W3C_ATTACHING_CAPTURED_HEADER)); + + + return params; + } + @Override public void willRedirectToStore() { Instabug.willRedirectToStore(); diff --git a/android/src/test/java/com/instabug/flutter/ApmApiTest.java b/android/src/test/java/com/instabug/flutter/ApmApiTest.java index 935521466..725d3bd98 100644 --- a/android/src/test/java/com/instabug/flutter/ApmApiTest.java +++ b/android/src/test/java/com/instabug/flutter/ApmApiTest.java @@ -1,5 +1,18 @@ package com.instabug.flutter; +import static com.instabug.flutter.util.GlobalMocks.reflected; +import static com.instabug.flutter.util.MockResult.makeResult; +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + import com.instabug.apm.APM; import com.instabug.apm.InternalAPM; import com.instabug.apm.configuration.cp.APMFeature; diff --git a/android/src/test/java/com/instabug/flutter/InstabugApiTest.java b/android/src/test/java/com/instabug/flutter/InstabugApiTest.java index 2abb8987e..3d0b15ed4 100644 --- a/android/src/test/java/com/instabug/flutter/InstabugApiTest.java +++ b/android/src/test/java/com/instabug/flutter/InstabugApiTest.java @@ -17,10 +17,16 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static io.mockk.MockKKt.every; +import static io.mockk.MockKKt.mockkObject; + +import io.mockk.*; + import android.app.Application; import android.graphics.Bitmap; import android.net.Uri; +import com.instabug.apm.InternalAPM; import com.instabug.bug.BugReporting; import com.instabug.flutter.generated.InstabugPigeon; import com.instabug.flutter.modules.InstabugApi; @@ -36,9 +42,15 @@ import com.instabug.library.ReproConfigurations; import com.instabug.library.ReproMode; import com.instabug.library.featuresflags.model.IBGFeatureFlag; +import com.instabug.library.internal.crossplatform.CoreFeature; +import com.instabug.library.internal.crossplatform.FeaturesStateListener; +import com.instabug.library.internal.crossplatform.InternalCore; +import com.instabug.library.featuresflags.model.IBGFeatureFlag; import com.instabug.library.invocation.InstabugInvocationEvent; import com.instabug.library.model.NetworkLog; import com.instabug.library.ui.onboarding.WelcomeMessage; +import com.instabug.survey.Surveys; +import com.instabug.survey.callbacks.OnShowCallback; import org.json.JSONObject; import org.junit.After; @@ -56,9 +68,16 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Random; import java.util.concurrent.Callable; import io.flutter.plugin.common.BinaryMessenger; +import kotlin.jvm.functions.Function1; + +import org.mockito.Mockito; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; +import org.mockito.verification.VerificationMode; import org.mockito.verification.VerificationMode; public class InstabugApiTest { @@ -69,11 +88,15 @@ public class InstabugApiTest { private MockedStatic mBugReporting; private MockedConstruction mCustomTextPlaceHolder; private MockedStatic mHostApi; - + private InternalCore internalCore; @Before public void setUp() throws NoSuchMethodException { mCustomTextPlaceHolder = mockConstruction(InstabugCustomTextPlaceHolder.class); - api = spy(new InstabugApi(mContext, screenshotProvider)); + internalCore=spy(InternalCore.INSTANCE); + + BinaryMessenger mMessenger = mock(BinaryMessenger.class); + final InstabugPigeon.FeatureFlagsFlutterApi flutterApi = new InstabugPigeon.FeatureFlagsFlutterApi(mMessenger); + api = spy(new InstabugApi(mContext, screenshotProvider, flutterApi)); mInstabug = mockStatic(Instabug.class); mBugReporting = mockStatic(BugReporting.class); mHostApi = mockStatic(InstabugPigeon.InstabugHostApi.class); @@ -87,6 +110,7 @@ public void cleanUp() { mBugReporting.close(); mHostApi.close(); GlobalMocks.close(); + } @Test @@ -349,11 +373,11 @@ public void testClearAllExperiments() { @Test public void testAddFeatureFlags() { - Map featureFlags = new HashMap<>(); - featureFlags.put("key1","variant1"); + Map featureFlags = new HashMap<>(); + featureFlags.put("key1", "variant1"); api.addFeatureFlags(featureFlags); - List flags=new ArrayList(); - flags.add(new IBGFeatureFlag("key1","variant1")); + List flags = new ArrayList(); + flags.add(new IBGFeatureFlag("key1", "variant1")); mInstabug.verify(() -> Instabug.addFeatureFlags(flags)); } @@ -598,4 +622,25 @@ public void testWillRedirectToStore() { api.willRedirectToStore(); mInstabug.verify(Instabug::willRedirectToStore); } + + + @Test + public void isW3CFeatureFlagsEnabled() { + mockkObject(new InternalCore[]{InternalCore.INSTANCE},false); + Random random=new Random(); + Boolean isW3cExternalGeneratedHeaderEnabled = random.nextBoolean(); + Boolean isW3cExternalTraceIDEnabled = random.nextBoolean(); + Boolean isW3cCaughtHeaderEnabled = random.nextBoolean(); + + every((Function1) mockKMatcherScope -> InternalCore.INSTANCE._isFeatureEnabled(CoreFeature.W3C_ATTACHING_GENERATED_HEADER)).returns(isW3cExternalGeneratedHeaderEnabled); + every((Function1) mockKMatcherScope -> InternalCore.INSTANCE._isFeatureEnabled(CoreFeature.W3C_EXTERNAL_TRACE_ID)).returns(isW3cExternalTraceIDEnabled); + every((Function1) mockKMatcherScope -> InternalCore.INSTANCE._isFeatureEnabled(CoreFeature.W3C_ATTACHING_CAPTURED_HEADER)).returns(isW3cCaughtHeaderEnabled); + + + Map flags = api.isW3CFeatureFlagsEnabled(); + assertEquals(isW3cExternalGeneratedHeaderEnabled, flags.get("isW3cExternalGeneratedHeaderEnabled")); + assertEquals(isW3cExternalTraceIDEnabled, flags.get("isW3cExternalTraceIDEnabled")); + assertEquals(isW3cCaughtHeaderEnabled, flags.get("isW3cCaughtHeaderEnabled")); + + } } diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 13ed775e2..698a32c86 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -26,7 +26,7 @@ apply plugin: 'kotlin-android' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { - compileSdkVersion flutter.compileSdkVersion + compileSdkVersion 34 ndkVersion flutter.ndkVersion compileOptions { @@ -44,8 +44,8 @@ android { defaultConfig { applicationId "com.instabug.flutter.example" - minSdkVersion flutter.minSdkVersion - targetSdkVersion 30 + minSdkVersion 21 + targetSdkVersion 34 versionCode flutterVersionCode.toInteger() versionName flutterVersionName testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -59,6 +59,7 @@ android { signingConfig signingConfigs.debug } } + namespace 'com.instabug.flutter.example' configurations.all { resolutionStrategy.force 'org.hamcrest:hamcrest-core:1.3' diff --git a/example/android/app/src/debug/AndroidManifest.xml b/example/android/app/src/debug/AndroidManifest.xml index ed546497f..f880684a6 100644 --- a/example/android/app/src/debug/AndroidManifest.xml +++ b/example/android/app/src/debug/AndroidManifest.xml @@ -1,5 +1,4 @@ - + diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 2416f09d0..483f4dd2e 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,4 @@ - + diff --git a/example/android/build.gradle b/example/android/build.gradle index 713d7f6e6..3a64d7e1e 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -6,7 +6,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:7.2.0' + classpath 'com.android.tools.build:gradle:8.0.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } @@ -19,6 +19,10 @@ allprojects { } rootProject.buildDir = '../build' + +//android { +// namespace 'com.instabug.flutter.example' +//} subprojects { project.buildDir = "${rootProject.buildDir}/${project.name}" } diff --git a/example/android/gradle.properties b/example/android/gradle.properties index 94adc3a3f..b9a9a2464 100644 --- a/example/android/gradle.properties +++ b/example/android/gradle.properties @@ -1,3 +1,6 @@ org.gradle.jvmargs=-Xmx1536M android.useAndroidX=true android.enableJetifier=true +android.defaults.buildfeatures.buildconfig=true +android.nonTransitiveRClass=false +android.nonFinalResIds=false diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties index 6b665338b..89e56bdb6 100644 --- a/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-all.zip diff --git a/example/ios/InstabugTests/InstabugApiTests.m b/example/ios/InstabugTests/InstabugApiTests.m index 3e83aec1c..6c9544d71 100644 --- a/example/ios/InstabugTests/InstabugApiTests.m +++ b/example/ios/InstabugTests/InstabugApiTests.m @@ -6,6 +6,8 @@ #import "Util/Instabug+Test.h" #import "IBGNetworkLogger+CP.h" #import "Flutter/Flutter.h" +#import "instabug_flutter/IBGAPM+PrivateAPIs.h" +#import "instabug_flutter/IBGNetworkLogger+CP.h" @interface InstabugTests : XCTestCase @@ -449,4 +451,155 @@ - (void)testWillRedirectToAppStore { OCMVerify([self.mInstabug willRedirectToAppStore]); } +- (void)testNetworkLogWithW3Caught { + NSString *url = @"https://example.com"; + NSString *requestBody = @"hi"; + NSNumber *requestBodySize = @17; + NSString *responseBody = @"{\"hello\":\"world\"}"; + NSNumber *responseBodySize = @153; + NSString *method = @"POST"; + NSNumber *responseCode = @201; + NSString *responseContentType = @"application/json"; + NSNumber *duration = @23000; + NSNumber *startTime = @1670156107523; + NSString *w3CCaughtHeader = @"1234"; + NSDictionary *requestHeaders = @{ @"Accepts": @"application/json",@"traceparent":w3CCaughtHeader}; + NSDictionary *responseHeaders = @{ @"Content-Type": @"text/plain" }; + NSDictionary *data = @{ + @"url": url, + @"requestBody": requestBody, + @"requestBodySize": requestBodySize, + @"responseBody": responseBody, + @"responseBodySize": responseBodySize, + @"method": method, + @"responseCode": responseCode, + @"requestHeaders": requestHeaders, + @"responseHeaders": responseHeaders, + @"responseContentType": responseContentType, + @"duration": duration, + @"startTime": startTime, + @"isW3cHeaderFound":@1, + @"w3CCaughtHeader":w3CCaughtHeader + }; + + FlutterError* error; + + [self.api networkLogData:data error:&error]; + + OCMVerify([self.mNetworkLogger addNetworkLogWithUrl:url + method:method + requestBody:requestBody + requestBodySize:requestBodySize.integerValue + responseBody:responseBody + responseBodySize:responseBodySize.integerValue + responseCode:(int32_t) responseCode.integerValue + requestHeaders:requestHeaders + responseHeaders:responseHeaders + contentType:responseContentType + errorDomain:nil + errorCode:0 + startTime:startTime.integerValue * 1000 + duration:duration.integerValue + gqlQueryName:nil + serverErrorMessage:nil + isW3cCaughted:@1 + partialID:nil + timestamp:nil + generatedW3CTraceparent:nil + caughtedW3CTraceparent:@"1234" + ]); +} + +- (void)testNetworkLogWithW3GeneratedHeader { + NSString *url = @"https://example.com"; + NSString *requestBody = @"hi"; + NSNumber *requestBodySize = @17; + NSString *responseBody = @"{\"hello\":\"world\"}"; + NSNumber *responseBodySize = @153; + NSString *method = @"POST"; + NSNumber *responseCode = @201; + NSString *responseContentType = @"application/json"; + NSNumber *duration = @23000; + NSNumber *startTime = @1670156107523; + NSDictionary *requestHeaders = @{ @"Accepts": @"application/json" }; + NSDictionary *responseHeaders = @{ @"Content-Type": @"text/plain" }; + NSNumber *partialID = @12; + + NSNumber *timestamp = @34; + + NSString *generatedW3CTraceparent = @"12-34"; + + NSString *caughtedW3CTraceparent = nil; + NSDictionary *data = @{ + @"url": url, + @"requestBody": requestBody, + @"requestBodySize": requestBodySize, + @"responseBody": responseBody, + @"responseBodySize": responseBodySize, + @"method": method, + @"responseCode": responseCode, + @"requestHeaders": requestHeaders, + @"responseHeaders": responseHeaders, + @"responseContentType": responseContentType, + @"duration": duration, + @"startTime": startTime, + @"isW3cHeaderFound": @0, + @"partialId": partialID, + @"networkStartTimeInSeconds": timestamp, + @"w3CGeneratedHeader": generatedW3CTraceparent, + + }; + NSNumber *isW3cCaughted = @0; + + FlutterError* error; + + [self.api networkLogData:data error:&error]; + + OCMVerify([self.mNetworkLogger addNetworkLogWithUrl:url + method:method + requestBody:requestBody + requestBodySize:requestBodySize.integerValue + responseBody:responseBody + responseBodySize:responseBodySize.integerValue + responseCode:(int32_t) responseCode.integerValue + requestHeaders:requestHeaders + responseHeaders:responseHeaders + contentType:responseContentType + errorDomain:nil + errorCode:0 + startTime:startTime.integerValue * 1000 + duration:duration.integerValue + gqlQueryName:nil + serverErrorMessage:nil + isW3cCaughted:isW3cCaughted + partialID:partialID + timestamp:timestamp + generatedW3CTraceparent:generatedW3CTraceparent + caughtedW3CTraceparent:caughtedW3CTraceparent + + + + ]); +} + +- (void)testisW3CFeatureFlagsEnabled { + FlutterError *error; + + id mock = OCMClassMock([IBGNetworkLogger class]); + NSNumber *isW3cExternalTraceIDEnabled = @(YES); + + OCMStub([mock w3ExternalTraceIDEnabled]).andReturn([isW3cExternalTraceIDEnabled boolValue]); + OCMStub([mock w3ExternalGeneratedHeaderEnabled]).andReturn([isW3cExternalTraceIDEnabled boolValue]); + OCMStub([mock w3CaughtHeaderEnabled]).andReturn([isW3cExternalTraceIDEnabled boolValue]); + + + + NSDictionary * result= [self.api isW3CFeatureFlagsEnabledWithError:&error]; + + XCTAssertEqual(result[@"isW3cExternalTraceIDEnabled"],isW3cExternalTraceIDEnabled); + XCTAssertEqual(result[@"isW3cExternalGeneratedHeaderEnabled"],isW3cExternalTraceIDEnabled); + XCTAssertEqual(result[@"isW3cCaughtHeaderEnabled"],isW3cExternalTraceIDEnabled); + +} + @end diff --git a/example/ios/InstabugTests/Util/IBGNetworkLogger+Test.h b/example/ios/InstabugTests/Util/IBGNetworkLogger+Test.h index 55475a81f..09f10eb8d 100644 --- a/example/ios/InstabugTests/Util/IBGNetworkLogger+Test.h +++ b/example/ios/InstabugTests/Util/IBGNetworkLogger+Test.h @@ -18,5 +18,10 @@ startTime:(int64_t)startTime duration:(int64_t) duration gqlQueryName:(NSString *_Nullable)gqlQueryName - serverErrorMessage:(NSString *_Nullable)gqlServerError; + serverErrorMessage:(NSString *_Nullable)gqlServerError + isW3cCaughted:(NSNumber *_Nullable)isW3cCaughted + partialID:(NSNumber *_Nullable)partialId + timestamp:(NSNumber *_Nullable)timestamp + generatedW3CTraceparent:(NSString *_Nullable)generatedW3CTraceparent + caughtedW3CTraceparent:(NSString *_Nullable)caughtedW3CTraceparent; @end diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index b0ee46890..f6cd7d9de 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1,9 +1,9 @@ PODS: - Flutter (1.0.0) - - Instabug (13.4.2) + - Instabug (14.0.0) - instabug_flutter (13.4.0): - Flutter - - Instabug (= 13.4.2) + - Instabug (= 14.0.0) - OCMock (3.6) DEPENDENCIES: @@ -24,10 +24,10 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - Instabug: 7a71890217b97b1e32dbca96661845396b66da2f - instabug_flutter: a2df87e3d4d9e410785e0b1ffef4bc64d1f4b787 + Instabug: a0beffc01658773e2fac549845782f8937707dc4 + instabug_flutter: 71ec9d13d57a4958cabab59fe06792cade3bf754 OCMock: 5ea90566be239f179ba766fd9fbae5885040b992 PODFILE CHECKSUM: 8f7552fd115ace1988c3db54a69e4a123c448f84 -COCOAPODS: 1.14.3 +COCOAPODS: 1.16.2 diff --git a/example/lib/src/components/network_content.dart b/example/lib/src/components/network_content.dart index f55af5c95..77ac744b2 100644 --- a/example/lib/src/components/network_content.dart +++ b/example/lib/src/components/network_content.dart @@ -26,14 +26,24 @@ class _NetworkContentState extends State { text: 'Send Request To Url', onPressed: () => _sendRequestToUrl(endpointUrlController.text), ), + Text("W3C Header Section"), + InstabugButton( + text: 'Send Request With Custom traceparent header', + onPressed: () => _sendRequestToUrl(endpointUrlController.text, + headers: {"traceparent": "Custom traceparent header"}), + ), + InstabugButton( + text: 'Send Request Without Custom traceparent header', + onPressed: () => _sendRequestToUrl(endpointUrlController.text), + ), ], ); } - void _sendRequestToUrl(String text) async { + void _sendRequestToUrl(String text, {Map? headers}) async { try { String url = text.trim().isEmpty ? widget.defaultRequestUrl : text; - final response = await http.get(Uri.parse(url)); + final response = await http.get(Uri.parse(url), headers: headers); // Handle the response here if (response.statusCode == 200) { diff --git a/example/pubspec.lock b/example/pubspec.lock index 31cb6f5dd..ebdc6b68c 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -41,14 +41,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" - espresso: - dependency: "direct dev" - description: - name: espresso - sha256: "641bdfcaec98b2fe2f5c90d61a16cdf6879ddac4d7333a6467ef03d60933596b" - url: "https://pub.dev" - source: hosted - version: "0.2.0+5" fake_async: dependency: transitive description: @@ -115,7 +107,7 @@ packages: path: ".." relative: true source: path - version: "13.4.0" + version: "14.0.0" instabug_http_client: dependency: "direct main" description: @@ -128,18 +120,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" url: "https://pub.dev" source: hosted - version: "10.0.4" + version: "10.0.5" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.5" leak_tracker_testing: dependency: transitive description: @@ -168,18 +160,18 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.15.0" path: dependency: transitive description: @@ -192,10 +184,10 @@ packages: dependency: transitive description: name: platform - sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" + sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.5" process: dependency: transitive description: @@ -261,18 +253,18 @@ packages: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.7.2" typed_data: dependency: transitive description: name: typed_data - sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.4.0" vector_math: dependency: transitive description: @@ -285,10 +277,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" url: "https://pub.dev" source: hosted - version: "14.2.1" + version: "14.2.5" webdriver: dependency: transitive description: @@ -298,5 +290,5 @@ packages: source: hosted version: "3.0.3" sdks: - dart: ">=3.3.0 <4.0.0" + dart: ">=3.5.0 <4.0.0" flutter: ">=3.18.0-18.0.pre.54" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 7f3e9e622..fe72aaa2d 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -29,7 +29,6 @@ dependencies: instabug_http_client: ^2.4.0 dev_dependencies: - espresso: 0.2.0+5 flutter_driver: sdk: flutter flutter_test: diff --git a/ios/Classes/Modules/InstabugApi.m b/ios/Classes/Modules/InstabugApi.m index 11ea09354..8cdd336d1 100644 --- a/ios/Classes/Modules/InstabugApi.m +++ b/ios/Classes/Modules/InstabugApi.m @@ -5,6 +5,7 @@ #import "IBGNetworkLogger+CP.h" #import "InstabugApi.h" #import "ArgsRegistry.h" +#import "../Util/IBGAPM+PrivateAPIs.h" #define UIColorFromRGB(rgbValue) [UIColor colorWithRed:((float)((rgbValue & 0xFF0000) >> 16)) / 255.0 green:((float)((rgbValue & 0xFF00) >> 8)) / 255.0 blue:((float)(rgbValue & 0xFF)) / 255.0 alpha:((float)((rgbValue & 0xFF000000) >> 24)) / 255.0]; @@ -279,12 +280,39 @@ - (void)networkLogData:(NSDictionary *)data error:(FlutterError NSString *gqlQueryName = nil; NSString *serverErrorMessage = nil; + NSNumber *isW3cHeaderFound = nil; + NSNumber *partialId = nil; + NSNumber *networkStartTimeInSeconds = nil; + NSString *w3CGeneratedHeader = nil; + NSString *w3CCaughtHeader = nil; + if (data[@"gqlQueryName"] != [NSNull null]) { gqlQueryName = data[@"gqlQueryName"]; } if (data[@"serverErrorMessage"] != [NSNull null]) { serverErrorMessage = data[@"serverErrorMessage"]; } + if (data[@"partialId"] != [NSNull null]) { + partialId = data[@"partialId"]; + } + + if (data[@"isW3cHeaderFound"] != [NSNull null]) { + isW3cHeaderFound = data[@"isW3cHeaderFound"]; + } + + if (data[@"networkStartTimeInSeconds"] != [NSNull null]) { + networkStartTimeInSeconds = data[@"networkStartTimeInSeconds"]; + } + + if (data[@"w3CGeneratedHeader"] != [NSNull null]) { + w3CGeneratedHeader = data[@"w3CGeneratedHeader"]; + } + + if (data[@"w3CCaughtHeader"] != [NSNull null]) { + w3CCaughtHeader = data[@"w3CCaughtHeader"]; + } + + [IBGNetworkLogger addNetworkLogWithUrl:url method:method @@ -302,11 +330,11 @@ - (void)networkLogData:(NSDictionary *)data error:(FlutterError duration:duration gqlQueryName:gqlQueryName serverErrorMessage:serverErrorMessage - isW3cCaughted:nil - partialID:nil - timestamp:nil - generatedW3CTraceparent:nil - caughtedW3CTraceparent:nil]; + isW3cCaughted:isW3cHeaderFound + partialID:partialId + timestamp:networkStartTimeInSeconds + generatedW3CTraceparent:w3CGeneratedHeader + caughtedW3CTraceparent:w3CCaughtHeader]; } - (void)willRedirectToStoreWithError:(FlutterError * _Nullable __autoreleasing *)error { @@ -348,5 +376,21 @@ - (void)removeFeatureFlagsFeatureFlags:(nonnull NSArray *)featureFla } } +- (void)registerFeatureFlagChangeListenerWithError:(FlutterError * _Nullable __autoreleasing * _Nonnull)error { + // Android only. We still need this method to exist to match the Pigeon-generated protocol. + +} + + +- (nullable NSDictionary *)isW3CFeatureFlagsEnabledWithError:(FlutterError * _Nullable __autoreleasing * _Nonnull)error { + NSDictionary *result= @{ + @"isW3cExternalTraceIDEnabled":[NSNumber numberWithBool:IBGNetworkLogger.w3ExternalTraceIDEnabled] , + @"isW3cExternalGeneratedHeaderEnabled":[NSNumber numberWithBool:IBGNetworkLogger.w3ExternalGeneratedHeaderEnabled] , + @"isW3cCaughtHeaderEnabled":[NSNumber numberWithBool:IBGNetworkLogger.w3CaughtHeaderEnabled] , + + }; + return result; +} + @end diff --git a/ios/Classes/Util/NativeUtils/IBGAPM+PrivateAPIs.h b/ios/Classes/Util/IBGAPM+PrivateAPIs.h similarity index 93% rename from ios/Classes/Util/NativeUtils/IBGAPM+PrivateAPIs.h rename to ios/Classes/Util/IBGAPM+PrivateAPIs.h index 2c2158479..e61bda308 100644 --- a/ios/Classes/Util/NativeUtils/IBGAPM+PrivateAPIs.h +++ b/ios/Classes/Util/IBGAPM+PrivateAPIs.h @@ -11,7 +11,6 @@ @interface IBGAPM (PrivateAPIs) -@property (class, atomic, assign) BOOL networkEnabled; /// `endScreenLoadingEnabled` will be only true if APM, screenLoadingFeature.enabled and autoUITracesUserPreference are true @property (class, atomic, assign) BOOL endScreenLoadingEnabled; diff --git a/ios/Classes/Util/IBGNetworkLogger+CP.h b/ios/Classes/Util/IBGNetworkLogger+CP.h index 764524fb2..244e3d195 100644 --- a/ios/Classes/Util/IBGNetworkLogger+CP.h +++ b/ios/Classes/Util/IBGNetworkLogger+CP.h @@ -2,7 +2,12 @@ NS_ASSUME_NONNULL_BEGIN -@interface IBGNetworkLogger (CP) + +@interface IBGNetworkLogger (PrivateAPIs) + +@property (class, atomic, assign) BOOL w3ExternalTraceIDEnabled; +@property (class, atomic, assign) BOOL w3ExternalGeneratedHeaderEnabled; +@property (class, atomic, assign) BOOL w3CaughtHeaderEnabled; + (void)disableAutomaticCapturingOfNetworkLogs; @@ -28,6 +33,22 @@ NS_ASSUME_NONNULL_BEGIN generatedW3CTraceparent:(NSString * _Nullable)generatedW3CTraceparent caughtedW3CTraceparent:(NSString * _Nullable)caughtedW3CTraceparent; ++ (void)addNetworkLogWithUrl:(NSString *_Nonnull)url + method:(NSString *_Nonnull)method + requestBody:(NSString *_Nonnull)request + requestBodySize:(int64_t)requestBodySize + responseBody:(NSString *_Nonnull)response + responseBodySize:(int64_t)responseBodySize + responseCode:(int32_t)code + requestHeaders:(NSDictionary *_Nonnull)requestHeaders + responseHeaders:(NSDictionary *_Nonnull)responseHeaders + contentType:(NSString *_Nonnull)contentType + errorDomain:(NSString *_Nullable)errorDomain + errorCode:(int32_t)errorCode + startTime:(int64_t)startTime + duration:(int64_t) duration + gqlQueryName:(NSString * _Nullable)gqlQueryName; + @end NS_ASSUME_NONNULL_END diff --git a/ios/instabug_flutter.podspec b/ios/instabug_flutter.podspec index 1d362767f..4d610ddbf 100644 --- a/ios/instabug_flutter.podspec +++ b/ios/instabug_flutter.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'instabug_flutter' - s.version = '13.4.0' + s.version = '14.0.0' s.summary = 'Flutter plugin for integrating the Instabug SDK.' s.author = 'Instabug' s.homepage = 'https://www.instabug.com/platforms/flutter' @@ -17,6 +17,6 @@ Pod::Spec.new do |s| s.pod_target_xcconfig = { 'OTHER_LDFLAGS' => '-framework "Flutter" -framework "Instabug"'} s.dependency 'Flutter' - s.dependency 'Instabug', '13.4.2' + s.dependency 'Instabug', '14.0.0' end diff --git a/lib/instabug_flutter.dart b/lib/instabug_flutter.dart index 6dc949d1b..e38545897 100644 --- a/lib/instabug_flutter.dart +++ b/lib/instabug_flutter.dart @@ -4,6 +4,8 @@ export 'src/models/exception_data.dart'; export 'src/models/feature_flag.dart'; export 'src/models/network_data.dart'; export 'src/models/trace.dart'; +export 'src/models/w3c_header.dart'; + // Modules export 'src/modules/apm.dart'; export 'src/modules/bug_reporting.dart'; diff --git a/lib/src/models/generated_w3c_header.dart b/lib/src/models/generated_w3c_header.dart new file mode 100644 index 000000000..dc1e4c51f --- /dev/null +++ b/lib/src/models/generated_w3c_header.dart @@ -0,0 +1,24 @@ +class GeneratedW3CHeader { + num timestampInSeconds; + int partialId; + String w3cHeader; + + GeneratedW3CHeader({ + required this.timestampInSeconds, + required this.partialId, + required this.w3cHeader, + }); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GeneratedW3CHeader && + runtimeType == other.runtimeType && + timestampInSeconds == other.timestampInSeconds && + partialId == other.partialId && + w3cHeader == other.w3cHeader; + + @override + int get hashCode => + timestampInSeconds.hashCode ^ partialId.hashCode ^ w3cHeader.hashCode; +} diff --git a/lib/src/models/network_data.dart b/lib/src/models/network_data.dart index 03a26abd2..6589bb109 100644 --- a/lib/src/models/network_data.dart +++ b/lib/src/models/network_data.dart @@ -1,5 +1,7 @@ +import 'package:instabug_flutter/src/models/w3c_header.dart'; + class NetworkData { - const NetworkData({ + NetworkData({ required this.url, required this.method, this.requestBody = '', @@ -16,7 +18,10 @@ class NetworkData { required this.startTime, this.errorCode = 0, this.errorDomain = '', - }); + W3CHeader? w3cHeader, + }) { + _w3cHeader = w3cHeader; + } final String url; final String method; @@ -34,6 +39,50 @@ class NetworkData { final DateTime startTime; final int errorCode; final String errorDomain; + W3CHeader? _w3cHeader; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is NetworkData && + runtimeType == other.runtimeType && + url == other.url && + method == other.method && + requestBody == other.requestBody && + responseBody == other.responseBody && + requestBodySize == other.requestBodySize && + responseBodySize == other.responseBodySize && + status == other.status && + requestHeaders == other.requestHeaders && + responseHeaders == other.responseHeaders && + duration == other.duration && + requestContentType == other.requestContentType && + responseContentType == other.responseContentType && + endTime == other.endTime && + startTime == other.startTime && + errorCode == other.errorCode && + errorDomain == other.errorDomain && + _w3cHeader == other._w3cHeader; + + @override + int get hashCode => + url.hashCode ^ + method.hashCode ^ + requestBody.hashCode ^ + responseBody.hashCode ^ + requestBodySize.hashCode ^ + responseBodySize.hashCode ^ + status.hashCode ^ + requestHeaders.hashCode ^ + responseHeaders.hashCode ^ + duration.hashCode ^ + requestContentType.hashCode ^ + responseContentType.hashCode ^ + endTime.hashCode ^ + startTime.hashCode ^ + errorCode.hashCode ^ + errorDomain.hashCode ^ + _w3cHeader.hashCode; NetworkData copyWith({ String? url, @@ -52,6 +101,7 @@ class NetworkData { DateTime? startTime, int? errorCode, String? errorDomain, + W3CHeader? w3cHeader, }) { return NetworkData( url: url ?? this.url, @@ -70,6 +120,7 @@ class NetworkData { startTime: startTime ?? this.startTime, errorCode: errorCode ?? this.errorCode, errorDomain: errorDomain ?? this.errorDomain, + w3cHeader: w3cHeader ?? _w3cHeader, ); } @@ -92,6 +143,11 @@ class NetworkData { 'responseBodySize': responseBodySize, 'errorDomain': errorDomain, 'errorCode': errorCode, + "isW3cHeaderFound": _w3cHeader?.isW3cHeaderFound, + "partialId": _w3cHeader?.partialId, + "networkStartTimeInSeconds": _w3cHeader?.networkStartTimeInSeconds, + "w3CGeneratedHeader": _w3cHeader?.w3CGeneratedHeader, + "w3CCaughtHeader": _w3cHeader?.w3CCaughtHeader, }; } } diff --git a/lib/src/models/trace_partial_id.dart b/lib/src/models/trace_partial_id.dart new file mode 100644 index 000000000..ac5e59b87 --- /dev/null +++ b/lib/src/models/trace_partial_id.dart @@ -0,0 +1,20 @@ +class TracePartialId { + int numberPartialId; + String hexPartialId; + + TracePartialId({ + required this.numberPartialId, + required this.hexPartialId, + }); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TracePartialId && + runtimeType == other.runtimeType && + numberPartialId == other.numberPartialId && + hexPartialId == other.hexPartialId); + + @override + int get hashCode => numberPartialId.hashCode ^ hexPartialId.hashCode; +} diff --git a/lib/src/models/w3c_feature_flags.dart b/lib/src/models/w3c_feature_flags.dart new file mode 100644 index 000000000..4a298e57b --- /dev/null +++ b/lib/src/models/w3c_feature_flags.dart @@ -0,0 +1,11 @@ +class W3cFeatureFlags { + bool isW3cExternalTraceIDEnabled; + bool isW3cExternalGeneratedHeaderEnabled; + bool isW3cCaughtHeaderEnabled; + + W3cFeatureFlags({ + required this.isW3cExternalTraceIDEnabled, + required this.isW3cExternalGeneratedHeaderEnabled, + required this.isW3cCaughtHeaderEnabled, + }); +} diff --git a/lib/src/models/w3c_header.dart b/lib/src/models/w3c_header.dart new file mode 100644 index 000000000..dfc7b67a2 --- /dev/null +++ b/lib/src/models/w3c_header.dart @@ -0,0 +1,34 @@ +class W3CHeader { + final bool? isW3cHeaderFound; + final num? partialId; + final num? networkStartTimeInSeconds; + final String? w3CGeneratedHeader; + final String? w3CCaughtHeader; + + W3CHeader({ + this.isW3cHeaderFound, + this.partialId, + this.networkStartTimeInSeconds, + this.w3CGeneratedHeader, + this.w3CCaughtHeader, + }); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is W3CHeader && + runtimeType == other.runtimeType && + isW3cHeaderFound == other.isW3cHeaderFound && + partialId == other.partialId && + networkStartTimeInSeconds == other.networkStartTimeInSeconds && + w3CGeneratedHeader == other.w3CGeneratedHeader && + w3CCaughtHeader == other.w3CCaughtHeader; + + @override + int get hashCode => + isW3cHeaderFound.hashCode ^ + partialId.hashCode ^ + networkStartTimeInSeconds.hashCode ^ + w3CGeneratedHeader.hashCode ^ + w3CCaughtHeader.hashCode; +} diff --git a/lib/src/modules/instabug.dart b/lib/src/modules/instabug.dart index bf457a4fb..766067df6 100644 --- a/lib/src/modules/instabug.dart +++ b/lib/src/modules/instabug.dart @@ -17,6 +17,7 @@ import 'package:flutter/services.dart'; import 'package:instabug_flutter/instabug_flutter.dart'; import 'package:instabug_flutter/src/generated/instabug.api.g.dart'; import 'package:instabug_flutter/src/utils/enum_converter.dart'; +import 'package:instabug_flutter/src/utils/feature_flags_manager.dart'; import 'package:instabug_flutter/src/utils/ibg_build_info.dart'; import 'package:instabug_flutter/src/utils/instabug_logger.dart'; import 'package:instabug_flutter/src/utils/screen_name_masker.dart'; @@ -185,11 +186,12 @@ class Instabug { }) async { $setup(); InstabugLogger.I.logLevel = debugLogsLevel; - return _host.init( + await _host.init( token, invocationEvents.mapToString(), debugLogsLevel.toString(), ); + return FeatureFlagsManager().registerW3CFlagsListener(); } /// Sets a [callback] to be called wehenever a screen name is captured to mask diff --git a/lib/src/modules/network_logger.dart b/lib/src/modules/network_logger.dart index 83a88538c..14e524f87 100644 --- a/lib/src/modules/network_logger.dart +++ b/lib/src/modules/network_logger.dart @@ -1,12 +1,15 @@ // ignore_for_file: avoid_classes_with_only_static_members import 'dart:async'; - -import 'package:flutter/foundation.dart'; import 'package:instabug_flutter/src/generated/instabug.api.g.dart'; import 'package:instabug_flutter/src/models/network_data.dart'; +import 'package:instabug_flutter/src/models/w3c_header.dart'; import 'package:instabug_flutter/src/modules/apm.dart'; +import 'package:instabug_flutter/src/utils/feature_flags_manager.dart'; +import 'package:instabug_flutter/src/utils/iterable_ext.dart'; import 'package:instabug_flutter/src/utils/network_manager.dart'; +import 'package:instabug_flutter/src/utils/w3c_header_utils.dart'; +import 'package:meta/meta.dart'; class NetworkLogger { static var _host = InstabugHostApi(); @@ -17,6 +20,8 @@ class NetworkLogger { // ignore: use_setters_to_change_properties static void $setHostApi(InstabugHostApi host) { _host = host; + // ignore: invalid_use_of_visible_for_testing_member + FeatureFlagsManager().$setHostApi(host); } /// @nodoc @@ -66,13 +71,59 @@ class NetworkLogger { } Future networkLog(NetworkData data) async { - final omit = await _manager.omitLog(data); + final w3Header = await getW3CHeader( + data.requestHeaders, + data.startTime.millisecondsSinceEpoch, + ); + if (w3Header?.isW3cHeaderFound == false && + w3Header?.w3CGeneratedHeader != null) { + data.requestHeaders['traceparent'] = w3Header?.w3CGeneratedHeader; + } + networkLogInternal(data); + } + @internal + Future networkLogInternal(NetworkData data) async { + final omit = await _manager.omitLog(data); if (omit) return; - final obfuscated = await _manager.obfuscateLog(data); - await _host.networkLog(obfuscated.toJson()); await APM.networkLogAndroid(obfuscated); } + + @internal + Future getW3CHeader( + Map header, + int startTime, + ) async { + final w3cFlags = await FeatureFlagsManager().getW3CFeatureFlagsHeader(); + + if (w3cFlags.isW3cExternalTraceIDEnabled == false) { + return null; + } + + final w3cHeaderFound = header.entries + .firstWhereOrNull( + (element) => element.key.toLowerCase() == 'traceparent', + ) + ?.value as String?; + final isW3cHeaderFound = w3cHeaderFound != null; + + if (isW3cHeaderFound && w3cFlags.isW3cCaughtHeaderEnabled) { + return W3CHeader(isW3cHeaderFound: true, w3CCaughtHeader: w3cHeaderFound); + } else if (w3cFlags.isW3cExternalGeneratedHeaderEnabled && + !isW3cHeaderFound) { + final w3cHeaderData = W3CHeaderUtils().generateW3CHeader( + startTime, + ); + + return W3CHeader( + isW3cHeaderFound: false, + partialId: w3cHeaderData.partialId, + networkStartTimeInSeconds: w3cHeaderData.timestampInSeconds, + w3CGeneratedHeader: w3cHeaderData.w3cHeader, + ); + } + return null; + } } diff --git a/lib/src/utils/feature_flags_manager.dart b/lib/src/utils/feature_flags_manager.dart new file mode 100644 index 000000000..b81dc9777 --- /dev/null +++ b/lib/src/utils/feature_flags_manager.dart @@ -0,0 +1,92 @@ +import 'package:instabug_flutter/src/generated/instabug.api.g.dart'; +import 'package:instabug_flutter/src/models/w3c_feature_flags.dart'; +import 'package:instabug_flutter/src/utils/ibg_build_info.dart'; +import 'package:meta/meta.dart'; + +typedef OnW3CFeatureFlagChange = void Function( + bool isW3cExternalTraceIDEnabled, + bool isW3cExternalGeneratedHeaderEnabled, + bool isW3cCaughtHeaderEnabled, +); + +class FeatureFlagsManager implements FeatureFlagsFlutterApi { + // Access the singleton instance + factory FeatureFlagsManager() { + return _instance; + } + // Private constructor to prevent instantiation from outside the class + FeatureFlagsManager._(); + + // Singleton instance + static final FeatureFlagsManager _instance = FeatureFlagsManager._(); + + // Host API instance + static InstabugHostApi _host = InstabugHostApi(); + + /// @nodoc + @visibleForTesting + // Setter for the host API + // ignore: use_setters_to_change_properties + void $setHostApi(InstabugHostApi host) { + _host = host; + } + + @visibleForTesting + // Setter for the FeatureFlagsManager + void setFeatureFlagsManager(FeatureFlagsManager featureFlagsManager) { + // This can be used for testing, but should be avoided in production + // since it breaks the singleton pattern + } + + // Internal state flags + bool _isAndroidW3CExternalTraceID = false; + bool _isAndroidW3CExternalGeneratedHeader = false; + bool _isAndroidW3CCaughtHeader = false; + + Future getW3CFeatureFlagsHeader() async { + if (IBGBuildInfo.instance.isAndroid) { + return Future.value( + W3cFeatureFlags( + isW3cCaughtHeaderEnabled: _isAndroidW3CCaughtHeader, + isW3cExternalGeneratedHeaderEnabled: + _isAndroidW3CExternalGeneratedHeader, + isW3cExternalTraceIDEnabled: _isAndroidW3CExternalTraceID, + ), + ); + } + final flags = await _host.isW3CFeatureFlagsEnabled(); + return W3cFeatureFlags( + isW3cCaughtHeaderEnabled: flags['isW3cCaughtHeaderEnabled'] ?? false, + isW3cExternalGeneratedHeaderEnabled: + flags['isW3cExternalGeneratedHeaderEnabled'] ?? false, + isW3cExternalTraceIDEnabled: + flags['isW3cExternalTraceIDEnabled'] ?? false, + ); + } + + Future registerW3CFlagsListener() async { + FeatureFlagsFlutterApi.setup(this); // Use 'this' instead of _instance + + final featureFlags = await _host.isW3CFeatureFlagsEnabled(); + _isAndroidW3CCaughtHeader = + featureFlags['isW3cCaughtHeaderEnabled'] ?? false; + _isAndroidW3CExternalTraceID = + featureFlags['isW3cExternalTraceIDEnabled'] ?? false; + _isAndroidW3CExternalGeneratedHeader = + featureFlags['isW3cExternalGeneratedHeaderEnabled'] ?? false; + + return _host.registerFeatureFlagChangeListener(); + } + + @override + @internal + void onW3CFeatureFlagChange( + bool isW3cExternalTraceIDEnabled, + bool isW3cExternalGeneratedHeaderEnabled, + bool isW3cCaughtHeaderEnabled, + ) { + _isAndroidW3CCaughtHeader = isW3cCaughtHeaderEnabled; + _isAndroidW3CExternalTraceID = isW3cExternalTraceIDEnabled; + _isAndroidW3CExternalGeneratedHeader = isW3cExternalGeneratedHeaderEnabled; + } +} diff --git a/lib/src/utils/iterable_ext.dart b/lib/src/utils/iterable_ext.dart new file mode 100644 index 000000000..e5c0099f5 --- /dev/null +++ b/lib/src/utils/iterable_ext.dart @@ -0,0 +1,8 @@ +extension IterableExtenstions on Iterable { + T? firstWhereOrNull(bool Function(T element) where) { + for (final element in this) { + if (where(element)) return element; + } + return null; + } +} diff --git a/lib/src/utils/w3c_header_utils.dart b/lib/src/utils/w3c_header_utils.dart new file mode 100644 index 000000000..26c3a8896 --- /dev/null +++ b/lib/src/utils/w3c_header_utils.dart @@ -0,0 +1,66 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:instabug_flutter/src/models/generated_w3c_header.dart'; +import 'package:instabug_flutter/src/models/trace_partial_id.dart'; + +class W3CHeaderUtils { + // Access the singleton instance + factory W3CHeaderUtils() { + return _instance; + } + // Private constructor to prevent instantiation + W3CHeaderUtils._(); + + // Singleton instance + static final W3CHeaderUtils _instance = W3CHeaderUtils._(); + + // Random instance + static Random _random = Random(); + + @visibleForTesting + // Setter for the Random instance + // ignore: use_setters_to_change_properties + void $setRandom(Random random) { + _random = random; + } + + /// Generate random 32-bit unsigned integer Hexadecimal (8 chars) lower case letters + /// Should not return all zeros + TracePartialId generateTracePartialId() { + int randomNumber; + String hexString; + + do { + randomNumber = _random.nextInt(0xffffffff); + hexString = randomNumber.toRadixString(16).padLeft(8, '0'); + } while (hexString == '00000000'); + + return TracePartialId( + numberPartialId: randomNumber, + hexPartialId: hexString.toLowerCase(), + ); + } + + /// Generate W3C header in the format of {version}-{trace-id}-{parent-id}-{trace-flag} + /// @param networkStartTime + /// @returns W3C header + GeneratedW3CHeader generateW3CHeader(int networkStartTime) { + final partialIdData = generateTracePartialId(); + final hexStringPartialId = partialIdData.hexPartialId; + final numberPartialId = partialIdData.numberPartialId; + + final timestampInSeconds = (networkStartTime / 1000).floor(); + final hexaDigitsTimestamp = + timestampInSeconds.toRadixString(16).toLowerCase(); + final traceId = + '$hexaDigitsTimestamp$hexStringPartialId$hexaDigitsTimestamp$hexStringPartialId'; + final parentId = '4942472d$hexStringPartialId'; + + return GeneratedW3CHeader( + timestampInSeconds: timestampInSeconds, + partialId: numberPartialId, + w3cHeader: '00-$traceId-$parentId-01', + ); + } +} diff --git a/pigeons/instabug.api.dart b/pigeons/instabug.api.dart index b839f8d1e..c0187acb9 100644 --- a/pigeons/instabug.api.dart +++ b/pigeons/instabug.api.dart @@ -1,5 +1,14 @@ import 'package:pigeon/pigeon.dart'; +@FlutterApi() +abstract class FeatureFlagsFlutterApi { + void onW3CFeatureFlagChange( + bool isW3cExternalTraceIDEnabled, + bool isW3cExternalGeneratedHeaderEnabled, + bool isW3cCaughtHeaderEnabled, + ); +} + @HostApi() abstract class InstabugHostApi { void setEnabled(bool isEnabled); @@ -60,5 +69,9 @@ abstract class InstabugHostApi { void networkLog(Map data); + void registerFeatureFlagChangeListener(); + + Map isW3CFeatureFlagsEnabled(); + void willRedirectToStore(); } diff --git a/pubspec.yaml b/pubspec.yaml index ba6dd0be2..05e312cdb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: instabug_flutter -version: 13.4.0 +version: 14.0.0 description: >- Instabug empowers mobile teams to monitor, prioritize, and debug performance and stability issues throughout the app development lifecycle. diff --git a/test/feature_flags_manager_test.dart b/test/feature_flags_manager_test.dart new file mode 100644 index 000000000..1a78f666c --- /dev/null +++ b/test/feature_flags_manager_test.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:instabug_flutter/src/generated/instabug.api.g.dart'; +import 'package:instabug_flutter/src/utils/feature_flags_manager.dart'; +import 'package:instabug_flutter/src/utils/ibg_build_info.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'feature_flags_manager_test.mocks.dart'; + +@GenerateMocks([InstabugHostApi, IBGBuildInfo]) +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + WidgetsFlutterBinding.ensureInitialized(); + + final mInstabugHost = MockInstabugHostApi(); + final mBuildInfo = MockIBGBuildInfo(); + + setUpAll(() { + FeatureFlagsManager().$setHostApi(mInstabugHost); + IBGBuildInfo.setInstance(mBuildInfo); + }); + + tearDown(() { + reset(mInstabugHost); + }); + + test('[getW3CFeatureFlagsHeader] should call host method on IOS', () async { + when(mBuildInfo.isAndroid).thenReturn(false); + when(mInstabugHost.isW3CFeatureFlagsEnabled()).thenAnswer( + (_) => Future.value({ + "isW3cExternalTraceIDEnabled": true, + "isW3cExternalGeneratedHeaderEnabled": true, + "isW3cCaughtHeaderEnabled": true, + }), + ); + final isW3CExternalTraceID = + await FeatureFlagsManager().getW3CFeatureFlagsHeader(); + expect(isW3CExternalTraceID.isW3cExternalTraceIDEnabled, true); + expect(isW3CExternalTraceID.isW3cExternalGeneratedHeaderEnabled, true); + expect(isW3CExternalTraceID.isW3cCaughtHeaderEnabled, true); + + verify( + mInstabugHost.isW3CFeatureFlagsEnabled(), + ).called(1); + }); + + test('[isW3CExternalTraceID] should call host method on Android', () async { + when(mBuildInfo.isAndroid).thenReturn(true); + when(mInstabugHost.isW3CFeatureFlagsEnabled()).thenAnswer( + (_) => Future.value({ + "isW3cExternalTraceIDEnabled": true, + "isW3cExternalGeneratedHeaderEnabled": true, + "isW3cCaughtHeaderEnabled": true, + }), + ); + await FeatureFlagsManager().registerW3CFlagsListener(); + + final isW3CExternalTraceID = + await FeatureFlagsManager().getW3CFeatureFlagsHeader(); + expect(isW3CExternalTraceID.isW3cExternalTraceIDEnabled, true); + expect(isW3CExternalTraceID.isW3cExternalGeneratedHeaderEnabled, true); + expect(isW3CExternalTraceID.isW3cCaughtHeaderEnabled, true); + verify( + mInstabugHost.isW3CFeatureFlagsEnabled(), + ).called(1); + }); + + test('[registerW3CFlagsListener] should call host method', () async { + when(mInstabugHost.isW3CFeatureFlagsEnabled()).thenAnswer( + (_) => Future.value({ + "isW3cExternalTraceIDEnabled": true, + "isW3cExternalGeneratedHeaderEnabled": true, + "isW3cCaughtHeaderEnabled": true, + }), + ); + + await FeatureFlagsManager().registerW3CFlagsListener(); + + verify( + mInstabugHost.registerFeatureFlagChangeListener(), + ).called(1); + }); +} diff --git a/test/instabug_test.dart b/test/instabug_test.dart index 0e6f0f421..e2fd7d298 100644 --- a/test/instabug_test.dart +++ b/test/instabug_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:instabug_flutter/instabug_flutter.dart'; import 'package:instabug_flutter/src/generated/instabug.api.g.dart'; import 'package:instabug_flutter/src/utils/enum_converter.dart'; +import 'package:instabug_flutter/src/utils/feature_flags_manager.dart'; import 'package:instabug_flutter/src/utils/ibg_build_info.dart'; import 'package:instabug_flutter/src/utils/screen_name_masker.dart'; import 'package:mockito/annotations.dart'; @@ -27,6 +28,7 @@ void main() { setUpAll(() { Instabug.$setHostApi(mHost); + FeatureFlagsManager().$setHostApi(mHost); IBGBuildInfo.setInstance(mBuildInfo); ScreenNameMasker.setInstance(mScreenNameMasker); }); @@ -69,7 +71,13 @@ void main() { test('[start] should call host method', () async { const token = "068ba9a8c3615035e163dc5f829c73be"; const events = [InvocationEvent.shake, InvocationEvent.screenshot]; - + when(mHost.isW3CFeatureFlagsEnabled()).thenAnswer( + (_) => Future.value({ + "isW3cExternalTraceIDEnabled": true, + "isW3cExternalGeneratedHeaderEnabled": true, + "isW3cCaughtHeaderEnabled": true, + }), + ); await Instabug.init( token: token, invocationEvents: events, diff --git a/test/network_logger_test.dart b/test/network_logger_test.dart index e5a04e236..77f5de51d 100644 --- a/test/network_logger_test.dart +++ b/test/network_logger_test.dart @@ -1,12 +1,15 @@ import 'dart:async'; +import 'dart:math'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:instabug_flutter/instabug_flutter.dart'; import 'package:instabug_flutter/src/generated/apm.api.g.dart'; import 'package:instabug_flutter/src/generated/instabug.api.g.dart'; +import 'package:instabug_flutter/src/utils/feature_flags_manager.dart'; import 'package:instabug_flutter/src/utils/ibg_build_info.dart'; import 'package:instabug_flutter/src/utils/network_manager.dart'; +import 'package:instabug_flutter/src/utils/w3c_header_utils.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; @@ -17,6 +20,9 @@ import 'network_logger_test.mocks.dart'; InstabugHostApi, IBGBuildInfo, NetworkManager, + W3CHeaderUtils, + FeatureFlagsManager, + Random, ]) void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -26,7 +32,7 @@ void main() { final mInstabugHost = MockInstabugHostApi(); final mBuildInfo = MockIBGBuildInfo(); final mManager = MockNetworkManager(); - + final mRandom = MockRandom(); final logger = NetworkLogger(); final data = NetworkData( url: "https://httpbin.org/get", @@ -36,6 +42,7 @@ void main() { setUpAll(() { APM.$setHostApi(mApmHost); + FeatureFlagsManager().$setHostApi(mInstabugHost); NetworkLogger.$setHostApi(mInstabugHost); NetworkLogger.$setManager(mManager); IBGBuildInfo.setInstance(mBuildInfo); @@ -46,6 +53,13 @@ void main() { reset(mInstabugHost); reset(mBuildInfo); reset(mManager); + when(mInstabugHost.isW3CFeatureFlagsEnabled()).thenAnswer( + (_) => Future.value({ + "isW3cExternalTraceIDEnabled": true, + "isW3cExternalGeneratedHeaderEnabled": true, + "isW3cCaughtHeaderEnabled": true, + }), + ); }); test('[networkLog] should call 1 host method on iOS', () async { @@ -53,7 +67,7 @@ void main() { when(mManager.obfuscateLog(data)).thenReturn(data); when(mManager.omitLog(data)).thenReturn(false); - await logger.networkLog(data); + await logger.networkLogInternal(data); verify( mInstabugHost.networkLog(data.toJson()), @@ -69,7 +83,7 @@ void main() { when(mManager.obfuscateLog(data)).thenReturn(data); when(mManager.omitLog(data)).thenReturn(false); - await logger.networkLog(data); + await logger.networkLogInternal(data); verify( mInstabugHost.networkLog(data.toJson()), @@ -87,7 +101,7 @@ void main() { when(mManager.obfuscateLog(data)).thenReturn(obfuscated); when(mManager.omitLog(data)).thenReturn(false); - await logger.networkLog(data); + await logger.networkLogInternal(data); verify( mManager.obfuscateLog(data), @@ -109,7 +123,7 @@ void main() { when(mManager.obfuscateLog(data)).thenReturn(data); when(mManager.omitLog(data)).thenReturn(omit); - await logger.networkLog(data); + await logger.networkLogInternal(data); verify( mManager.omitLog(data), @@ -143,4 +157,76 @@ void main() { mManager.setOmitLogCallback(callback), ).called(1); }); + + test( + '[getW3CHeader] should return null when isW3cExternalTraceIDEnabled disabled', + () async { + when(mBuildInfo.isAndroid).thenReturn(true); + + when(mInstabugHost.isW3CFeatureFlagsEnabled()).thenAnswer( + (_) => Future.value({ + "isW3cExternalTraceIDEnabled": false, + "isW3cExternalGeneratedHeaderEnabled": false, + "isW3cCaughtHeaderEnabled": false, + }), + ); + final time = DateTime.now().millisecondsSinceEpoch; + final w3cHeader = await logger.getW3CHeader({}, time); + expect(w3cHeader, null); + }); + + test( + '[getW3CHeader] should return transparent header when isW3cCaughtHeaderEnabled enabled', + () async { + when(mBuildInfo.isAndroid).thenReturn(false); + + final time = DateTime.now().millisecondsSinceEpoch; + final w3cHeader = + await logger.getW3CHeader({"traceparent": "Header test"}, time); + expect(w3cHeader!.isW3cHeaderFound, true); + expect(w3cHeader.w3CCaughtHeader, "Header test"); + }); + + test( + '[getW3CHeader] should return generated header when isW3cExternalGeneratedHeaderEnabled and no traceparent header', + () async { + W3CHeaderUtils().$setRandom(mRandom); + when(mBuildInfo.isAndroid).thenReturn(false); + + when(mRandom.nextInt(any)).thenReturn(217222); + + final time = DateTime.now().millisecondsSinceEpoch; + final w3cHeader = await logger.getW3CHeader({}, time); + final generatedW3CHeader = W3CHeaderUtils().generateW3CHeader(time); + + expect(w3cHeader!.isW3cHeaderFound, false); + expect(w3cHeader.w3CGeneratedHeader, generatedW3CHeader.w3cHeader); + expect(w3cHeader.partialId, generatedW3CHeader.partialId); + expect( + w3cHeader.networkStartTimeInSeconds, + generatedW3CHeader.timestampInSeconds, + ); + }); + + test( + '[networkLog] should add transparent header when isW3cCaughtHeaderEnabled disabled to every request', + () async { + final networkData = data.copyWith(requestHeaders: {}); + when(mBuildInfo.isAndroid).thenReturn(false); + when(mManager.obfuscateLog(networkData)).thenReturn(networkData); + when(mManager.omitLog(networkData)).thenReturn(false); + await logger.networkLog(networkData); + expect(networkData.requestHeaders.containsKey('traceparent'), isTrue); + }); + + test( + '[networkLog] should not add transparent header when there is traceparent', + () async { + final networkData = data.copyWith(requestHeaders: {'traceparent': 'test'}); + when(mBuildInfo.isAndroid).thenReturn(false); + when(mManager.obfuscateLog(networkData)).thenReturn(networkData); + when(mManager.omitLog(networkData)).thenReturn(false); + await logger.networkLog(networkData); + expect(networkData.requestHeaders['traceparent'], 'test'); + }); } diff --git a/test/w3_header_utils_test.dart b/test/w3_header_utils_test.dart new file mode 100644 index 000000000..1c10c45cf --- /dev/null +++ b/test/w3_header_utils_test.dart @@ -0,0 +1,68 @@ +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:instabug_flutter/src/models/generated_w3c_header.dart'; +import 'package:instabug_flutter/src/utils/w3c_header_utils.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'w3_header_utils_test.mocks.dart'; + +@GenerateMocks([Random]) +void main() { + final mRandom = MockRandom(); + + setUpAll(() { + W3CHeaderUtils().$setRandom(mRandom); + }); + setUp(() { + when(mRandom.nextInt(any)).thenReturn(217222); + }); + + tearDown(() { + reset(mRandom); + }); + + test('generateTracePartialId should generate a non-zero hex string', () { + var callCount = 0; + + when(mRandom.nextInt(any)).thenAnswer((_) => [0, 217222][callCount++]); + + final hexString = W3CHeaderUtils().generateTracePartialId().hexPartialId; + + expect(hexString, isNot('00000000')); + }); + + test('generateTracePartialId should return 8 chars long generated hex string', + () { + final hexString = W3CHeaderUtils().generateTracePartialId().hexPartialId; + expect(hexString.length, 8); + }); + + test( + 'generateW3CHeader should return {version}-{trace-id}-{parent-id}-{trace-flag} format header', + () { + const date = 1716210104248; + const partialId = 217222; + final hexString0 = partialId.toRadixString(16).padLeft(8, '0'); + + final expectedHeader = GeneratedW3CHeader( + timestampInSeconds: (date / 1000).floor(), + partialId: partialId, + w3cHeader: + '00-664b49b8${hexString0}664b49b8$hexString0-4942472d$hexString0-01', + ); + final generatedHeader = W3CHeaderUtils().generateW3CHeader(date); + expect(generatedHeader, expectedHeader); + }); + + test('generateW3CHeader should correctly floor the timestamp', () { + const date = 1716222912145; + final expectedHeader = GeneratedW3CHeader( + timestampInSeconds: (date / 1000).floor(), + partialId: 217222, + w3cHeader: "00-664b7bc000035086664b7bc000035086-4942472d00035086-01", + ); + final generatedHeader = W3CHeaderUtils().generateW3CHeader(date); + expect(generatedHeader, expectedHeader); + }); +}