diff --git a/toolkit/components/nimbus/lib/NimbusFeatures.cpp b/toolkit/components/nimbus/lib/NimbusFeatures.cpp index 5aac828b9d1f..95d9c089a041 100644 --- a/toolkit/components/nimbus/lib/NimbusFeatures.cpp +++ b/toolkit/components/nimbus/lib/NimbusFeatures.cpp @@ -5,9 +5,16 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ #include "mozilla/browser/NimbusFeatures.h" +#include "mozilla/Telemetry.h" +#include "mozilla/dom/ScriptSettings.h" +#include "jsapi.h" +#include "js/JSON.h" +#include "nsJSUtils.h" namespace mozilla { +static nsTHashSet sExposureFeatureSet; + void NimbusFeatures::GetPrefName(const nsACString& aFeatureId, const nsACString& aVariable, nsACString& aPref) { @@ -16,8 +23,10 @@ void NimbusFeatures::GetPrefName(const nsACString& aFeatureId, aPref.Truncate(); aPref.Append(kSyncDataPrefBranch); aPref.Append(aFeatureId); - aPref.Append("."); - aPref.Append(aVariable); + if (!aVariable.IsEmpty()) { + aPref.Append("."); + aPref.Append(aVariable); + } } bool NimbusFeatures::GetBool(const nsACString& aFeatureId, @@ -52,4 +61,93 @@ nsresult NimbusFeatures::OffUpdate(const nsACString& aFeatureId, return Preferences::UnregisterCallback(aUserCallback, pref, aUserData); } +/** + * Attempt to read Nimbus preference to determine experiment and branch slug. + * Nimbus will store a pref with experiment metadata in the following format: + * { + * slug: "experiment slug", + * branch: { slug: "branch slug" }, + * ... + * } + * The naming convention for preference names is: + * `nimbus.syncdatastore.` + * These values are used to send `exposure` telemetry pings. + */ +nsresult NimbusFeatures::GetExperimentSlug(const nsACString& aFeatureId, + nsACString& aExperimentSlug, + nsACString& aBranchSlug) { + nsAutoCString prefName; + nsAutoString prefValue; + + aExperimentSlug.Truncate(); + aBranchSlug.Truncate(); + + GetPrefName(aFeatureId, ""_ns, prefName); + MOZ_TRY(Preferences::GetString(prefName.get(), prefValue)); + if (prefValue.IsEmpty()) { + return NS_ERROR_UNEXPECTED; + } + dom::AutoJSAPI jsapi; + if (!jsapi.Init(xpc::PrivilegedJunkScope())) { + return NS_ERROR_UNEXPECTED; + } + JSContext* cx = jsapi.cx(); + JS::Rooted json(cx, JS::NullValue()); + if (JS_ParseJSON(cx, prefValue.BeginReading(), prefValue.Length(), &json) && + json.isObject()) { + JS::Rooted experimentJSON(cx, json.toObjectOrNull()); + JS::RootedValue expSlugValue(cx); + if (!JS_GetProperty(cx, experimentJSON, "slug", &expSlugValue)) { + return NS_ERROR_UNEXPECTED; + } + AssignJSString(cx, aExperimentSlug, expSlugValue.toString()); + + JS::RootedValue branchJSON(cx); + if (!JS_GetProperty(cx, experimentJSON, "branch", &branchJSON) && + !branchJSON.isObject()) { + return NS_ERROR_UNEXPECTED; + } + JS::Rooted branchObj(cx, branchJSON.toObjectOrNull()); + JS::RootedValue branchSlugValue(cx); + if (!JS_GetProperty(cx, branchObj, "slug", &branchSlugValue)) { + return NS_ERROR_UNEXPECTED; + } + AssignJSString(cx, aBranchSlug, branchSlugValue.toString()); + } + + return NS_OK; +} + +/** + * Sends an exposure event for aFeatureId when enrolled in an experiment. + * By default we only attempt to send once. For some usecases it might be useful + * to send multiple times or retry to send (when for example we are enrolled + * after the first call to this function) in which case set the optional + * aForce to `true`. + */ +nsresult NimbusFeatures::RecordExposureEvent(const nsACString& aFeatureId, + const bool aForce) { + nsAutoCString featureName(aFeatureId); + if (!sExposureFeatureSet.EnsureInserted(featureName) && !aForce) { + // We already sent (or tried to send) an exposure ping for this featureId + return NS_ERROR_ABORT; + } + nsAutoCString slugName; + nsAutoCString branchName; + MOZ_TRY(GetExperimentSlug(aFeatureId, slugName, branchName)); + if (slugName.IsEmpty() || branchName.IsEmpty()) { + // Failed getting experiment metadata or not enrolled in an experiment for + // this featureId + return NS_ERROR_UNEXPECTED; + } + Telemetry::SetEventRecordingEnabled("normandy"_ns, true); + nsTArray extra(2); + extra.AppendElement(Telemetry::EventExtraEntry{"branchSlug"_ns, branchName}); + extra.AppendElement(Telemetry::EventExtraEntry{"featureId"_ns, featureName}); + Telemetry::RecordEvent(Telemetry::EventID::Normandy_Expose_NimbusExperiment, + Some(slugName), Some(std::move(extra))); + + return NS_OK; +} + } // namespace mozilla diff --git a/toolkit/components/nimbus/lib/NimbusFeatures.h b/toolkit/components/nimbus/lib/NimbusFeatures.h index d30bef1f2131..e5d3a9691b0f 100644 --- a/toolkit/components/nimbus/lib/NimbusFeatures.h +++ b/toolkit/components/nimbus/lib/NimbusFeatures.h @@ -8,6 +8,7 @@ #define mozilla_NimbusFeatures_h #include "mozilla/Preferences.h" +#include "nsTHashSet.h" namespace mozilla { @@ -16,6 +17,10 @@ class NimbusFeatures { static void GetPrefName(const nsACString& aFeatureId, const nsACString& aVariable, nsACString& aPref); + static nsresult GetExperimentSlug(const nsACString& aFeatureId, + nsACString& aExperimentSlug, + nsACString& aBranchSlug); + public: static bool GetBool(const nsACString& aFeatureId, const nsACString& aVariable, bool aDefault); @@ -30,6 +35,9 @@ class NimbusFeatures { static nsresult OffUpdate(const nsACString& aFeatureId, const nsACString& aVariable, PrefChangedFunc aUserCallback, void* aUserData); + + static nsresult RecordExposureEvent(const nsACString& aFeatureId, + const bool aForce = false); }; } // namespace mozilla diff --git a/toolkit/components/nimbus/test/gtest/NimbusFeatures_RecordExposure.cpp b/toolkit/components/nimbus/test/gtest/NimbusFeatures_RecordExposure.cpp new file mode 100644 index 000000000000..cbb296d5996b --- /dev/null +++ b/toolkit/components/nimbus/test/gtest/NimbusFeatures_RecordExposure.cpp @@ -0,0 +1,44 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "gtest/gtest.h" +#include "mozilla/Preferences.h" +#include "mozilla/browser/NimbusFeatures.h" +#include "js/Array.h" +#include "js/PropertyAndElement.h" +#include "js/TypeDecls.h" +#include "TelemetryFixture.h" +#include "TelemetryTestHelpers.h" + +using namespace mozilla; +using namespace TelemetryTestHelpers; + +class NimbusTelemetryFixture : public TelemetryTestFixture {}; + +TEST_F(NimbusTelemetryFixture, NimbusFeaturesTelemetry) { + constexpr auto prefName = "nimbus.syncdatastore.foo"_ns; + constexpr auto prefValue = + R"({"slug":"experiment-slug","branch":{"slug":"branch-slug"}})"; + AutoJSContextWithGlobal cx(mCleanGlobal); + Unused << mTelemetry->ClearEvents(); + + ASSERT_EQ(NimbusFeatures::RecordExposureEvent("foo"_ns), NS_ERROR_UNEXPECTED) + << "Should fail because not enrolled in experiment"; + // Set the experiment info for `foo` + Preferences::SetCString(prefName.get(), prefValue); + ASSERT_EQ(NimbusFeatures::RecordExposureEvent("foo"_ns), NS_ERROR_ABORT) + << "Should fail even though enrolled because this is the 2nd call"; + ASSERT_EQ(NimbusFeatures::RecordExposureEvent("foo"_ns, true), NS_OK) + << "Should work because we set aForce=true"; + ASSERT_EQ(NimbusFeatures::RecordExposureEvent("bar"_ns), NS_ERROR_UNEXPECTED) + << "Should fail because we don't have an experiment for bar"; + ASSERT_EQ(NimbusFeatures::RecordExposureEvent("foo"_ns), NS_ERROR_ABORT) + << "Should abort because we've already send exposure for this featureId"; + JS::RootedValue eventsSnapshot(cx.GetJSContext()); + GetEventSnapshot(cx.GetJSContext(), &eventsSnapshot); + ASSERT_TRUE(EventPresent(cx.GetJSContext(), eventsSnapshot, "normandy"_ns, + "expose"_ns, "nimbus_experiment"_ns)); +} diff --git a/toolkit/components/nimbus/test/gtest/moz.build b/toolkit/components/nimbus/test/gtest/moz.build index 6f93b2b76cc3..41befbcd461e 100644 --- a/toolkit/components/nimbus/test/gtest/moz.build +++ b/toolkit/components/nimbus/test/gtest/moz.build @@ -6,6 +6,11 @@ UNIFIED_SOURCES += [ "NimbusFeatures_GetTest.cpp", + "NimbusFeatures_RecordExposure.cpp", +] + +LOCAL_INCLUDES += [ + "/toolkit/components/telemetry/tests/gtest", ] FINAL_LIBRARY = "xul-gtest"