diff --git a/CHANGELOG.md b/CHANGELOG.md
index ad3fbe483d..43a70f85ca 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,9 @@
[Full changelog](https://github.com/mozilla/glean/compare/v32.3.2...main)
+* General
+ * Allow using quantity metric type outside of Gecko ([#1198](https://github.com/mozilla/glean/pull/1198))
+
# v32.3.2 (2020-09-11)
[Full changelog](https://github.com/mozilla/glean/compare/v32.3.1...v32.3.2)
diff --git a/docs/user/metrics/counter.md b/docs/user/metrics/counter.md
index 2990564cd5..b76df0b9e0 100644
--- a/docs/user/metrics/counter.md
+++ b/docs/user/metrics/counter.md
@@ -142,7 +142,7 @@ using static Mozilla.YourApplication.GleanMetrics.Controls;
Assert.True(Controls.refreshPressed.TestHasValue());
// Does the counter have the expected value?
Assert.Equal(6, Controls.refreshPressed.TestGetValue());
-// Did the counter record an negative value?
+// Did the counter record a negative value?
Assert.Equal(
1, Controls.refreshPressed.TestGetNumRecordedErrors(ErrorType.InvalidValue)
);
diff --git a/docs/user/metrics/quantity.md b/docs/user/metrics/quantity.md
index 0e59edc99d..c753d57e11 100644
--- a/docs/user/metrics/quantity.md
+++ b/docs/user/metrics/quantity.md
@@ -1,22 +1,21 @@
# Quantity
-Used to record a single non-negative integer value.
+Used to record a single non-negative integer value or 0.
For example, the width of the display in pixels.
-> **Note**: Quantities are currently only allowed for GeckoView metrics (the `gecko_datapoint` parameter is present) and thus have only a Kotlin API.
+> **IMPORTANT** If you need to _count_ something (e.g. number of tabs open or number of times a button is pressed) prefer using the [Counter](./counter.md) metric type, which has a specific API for counting things and also takes care of resetting the count at the correct time.
## Configuration
Say you're adding a new quantity for the width of the display in pixels. First you need to add an entry for the quantity to the `metrics.yaml` file:
```YAML
-gfx:
- display_width:
+display:
+ width:
type: quantity
description: >
The width of the display, in pixels.
unit: pixels
- gecko_datapoint: DISPLAY_W_PIXELS
...
```
@@ -24,30 +23,130 @@ Note that quantities have a required `unit` parameter, which is a free-form stri
## API
+{{#include ../../tab_header.md}}
+
+
+
```Kotlin
-import org.mozilla.yourApplication.GleanMetrics.Gfx
+import org.mozilla.yourApplication.GleanMetrics.Display
-Gfx.displayWidth.set(width)
+Display.width.set(width)
```
There are test APIs available too:
```Kotlin
-import org.mozilla.yourApplication.GleanMetrics.Gfx
+import org.mozilla.yourApplication.GleanMetrics.Display
// Was anything recorded?
-assertTrue(Gfx.displayWidth.testHasValue())
+assertTrue(Display.width.testHasValue())
// Does the quantity have the expected value?
-assertEquals(6, Gfx.displayWidth.testGetValue())
+assertEquals(6, Display.width.testGetValue())
// Did it record an error due to a negative value?
-assertEquals(1, Gfx.displayWidth.testGetNumRecordedErrors(ErrorType.InvalidValue))
+assertEquals(1, Display.width.testGetNumRecordedErrors(ErrorType.InvalidValue))
```
-## Limits
+
+
+```Java
+import org.mozilla.yourApplication.GleanMetrics.Display;
+
+Display.INSTANCE.width.set(width);
+```
+
+There are test APIs available too:
+
+```Java
+import org.mozilla.yourApplication.GleanMetrics.Display;
+
+// Was anything recorded?
+assertTrue(Display.INSTANCE.width.testHasValue());
+// Does the quantity have the expected value?
+assertEquals(6, Display.INSTANCE.width.testGetValue());
+// Did the quantity record a negative value?
+assertEquals(
+ 1, Display.INSTANCE.width.testGetNumRecordedErrors(ErrorType.InvalidValue)
+);
+```
+
+
+
+
+
+```Swift
+Display.width.set(width)
+```
+
+There are test APIs available too:
+
+```Swift
+@testable import Glean
+
+// Was anything recorded?
+XCTAssert(Display.width.testHasValue())
+// Does the quantity have the expected value?
+XCTAssertEqual(6, try Display.width.testGetValue())
+// Did the quantity record a negative value?
+XCTAssertEqual(1, Display.width.testGetNumRecordedErrors(.invalidValue))
+```
-* Quantities must be non-negative integers.
+
+
+
+
+```Python
+from glean import load_metrics
+metrics = load_metrics("metrics.yaml")
+
+metrics.display.width.set(width)
+```
+
+There are test APIs available too:
+
+```Python
+# Was anything recorded?
+assert metrics.display.width.test_has_value()
+# Does the quantity have the expected value?
+assert 6 == metrics.display.width.test_get_value()
+# Did the quantity record an negative value?
+from glean.testing import ErrorType
+assert 1 == metrics.display.width.test_get_num_recorded_errors(
+ ErrorType.INVALID_VALUE
+)
+```
+
+
+
+
+
+```C#
+using static Mozilla.YourApplication.GleanMetrics.Display;
+
+Display.width.Set(width);
+```
+
+There are test APIs available too:
+
+```C#
+using static Mozilla.YourApplication.GleanMetrics.Display;
+
+// Was anything recorded?
+Assert.True(Display.width.TestHasValue());
+// Does the counter have the expected value?
+Assert.Equal(6, Display.width.TestGetValue());
+// Did the counter record an negative value?
+Assert.Equal(
+ 1, Display.width.TestGetNumRecordedErrors(ErrorType.InvalidValue)
+);
+```
+
+
+
+{{#include ../../tab_footer.md}}
+
+## Limits
-* Quantities are only available for metrics that come from Gecko.
+* Quantities must be non-negative integers or 0.
## Examples
diff --git a/glean-core/android/src/test/java/mozilla/telemetry/glean/private/QuantityMetricTypeTest.kt b/glean-core/android/src/test/java/mozilla/telemetry/glean/private/QuantityMetricTypeTest.kt
index c70adaf53f..e1ffe3b1ed 100644
--- a/glean-core/android/src/test/java/mozilla/telemetry/glean/private/QuantityMetricTypeTest.kt
+++ b/glean-core/android/src/test/java/mozilla/telemetry/glean/private/QuantityMetricTypeTest.kt
@@ -128,7 +128,7 @@ class QuantityMetricTypeTest {
}
@Test
- fun `negative values are not counted`() {
+ fun `negative values are not recorded`() {
// Define a 'quantityMetric' quantity metric, which will be stored in "store1"
val quantityMetric = QuantityMetricType(
disabled = false,
@@ -139,7 +139,7 @@ class QuantityMetricTypeTest {
)
quantityMetric.set(-10L)
- // Check that count was NOT incremented.
+ // Check that quantity was NOT recorded
assertFalse(quantityMetric.testHasValue("store1"))
// Make sure that the errors have been recorded
diff --git a/glean-core/csharp/Glean/LibGleanFFI.cs b/glean-core/csharp/Glean/LibGleanFFI.cs
index 5ecf3ca123..13667e1ff6 100644
--- a/glean-core/csharp/Glean/LibGleanFFI.cs
+++ b/glean-core/csharp/Glean/LibGleanFFI.cs
@@ -636,6 +636,37 @@ internal static extern Int32 glean_string_list_test_get_num_recorded_errors(
string storage_name
);
+ // Quantity
+
+ [DllImport(SharedGleanLibrary, ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)]
+ internal static extern UInt64 glean_new_quantity_metric(
+ string category,
+ string name,
+ string[] send_in_pings,
+ Int32 send_in_pings_len,
+ Int32 lifetime,
+ bool disabled
+ );
+
+ [DllImport(SharedGleanLibrary, ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)]
+ internal static extern void glean_destroy_quantity_metric(IntPtr handle);
+
+ [DllImport(SharedGleanLibrary, ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)]
+ internal static extern void glean_quantity_set(UInt64 metric_id, Int32 value);
+
+ [DllImport(SharedGleanLibrary, ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)]
+ internal static extern Int32 glean_quantity_test_get_value(UInt64 metric_id, string storage_name);
+
+ [DllImport(SharedGleanLibrary, ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)]
+ internal static extern bool glean_quantity_test_has_value(UInt64 metric_id, string storage_name);
+
+ [DllImport(SharedGleanLibrary, ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)]
+ internal static extern Int32 glean_quantity_test_get_num_recorded_errors(
+ UInt64 metric_id,
+ Int32 error_type,
+ String storage_name
+ );
+
// Custom pings
[DllImport(SharedGleanLibrary, ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)]
diff --git a/glean-core/csharp/Glean/Metrics/QuantityMetricType.cs b/glean-core/csharp/Glean/Metrics/QuantityMetricType.cs
new file mode 100644
index 0000000000..41b1eca222
--- /dev/null
+++ b/glean-core/csharp/Glean/Metrics/QuantityMetricType.cs
@@ -0,0 +1,128 @@
+// 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/.
+
+using Mozilla.Glean.FFI;
+using System;
+
+namespace Mozilla.Glean.Private
+{
+ ///
+ /// This implements the developer facing API for recording quantity metrics.
+ ///
+ /// Instances of this class type are automatically generated by the parsers at build time,
+ /// allowing developers to record values that were previously registered in the metrics.yaml file.
+ ///
+ /// The quantity API only exposes the [set] method.
+ //
+ ///
+ public sealed class QuantityMetricType {
+ private bool disabled;
+ private string[] sendInPings;
+ private UInt64 handle;
+
+ ///
+ /// The public constructor used by automatically generated metrics.
+ ///
+ public QuantityMetricType(
+ bool disabled,
+ string category,
+ Lifetime lifetime,
+ string name,
+ string[] sendInPings
+ ) : this(0, disabled, sendInPings)
+ {
+ handle = LibGleanFFI.glean_new_quantity_metric(
+ category: category,
+ name: name,
+ send_in_pings: sendInPings,
+ send_in_pings_len: sendInPings.Length,
+ lifetime: (int)lifetime,
+ disabled: disabled);
+ }
+
+ internal QuantityMetricType(
+ UInt64 handle,
+ bool disabled,
+ string[] sendInPings
+ )
+ {
+ this.disabled = disabled;
+ this.sendInPings = sendInPings;
+ this.handle = handle;
+ }
+
+
+ ///
+ /// Set a quantity value.
+ ///
+ /// The value to set. Must be non-negative.
+ public void Set(Int32 value)
+ {
+ if (disabled)
+ {
+ return;
+ }
+
+ Dispatchers.LaunchAPI(() => {
+ LibGleanFFI.glean_quantity_set(this.handle, value);
+ });
+ }
+
+
+ ///
+ /// Tests whether a value is stored for the metric for testing purposes only. This function will
+ /// attempt to await the last task (if any) writing to the the metric's storage engine before
+ /// returning a value.
+ ///
+ /// represents the name of the ping to retrieve the metric for Defaults
+ /// to the first value in `sendInPings`
+ /// true if metric value exists, otherwise false
+ public bool TestHasValue(string pingName = null)
+ {
+ Dispatchers.AssertInTestingMode();
+
+ string ping = pingName ?? sendInPings[0];
+ return LibGleanFFI.glean_quantity_test_has_value(this.handle, ping);
+ }
+
+ ///
+ /// Returns the stored value for testing purposes only. This function will attempt to await the
+ /// last task (if any) writing to the the metric's storage engine before returning a value.
+ /// @throws [NullPointerException] if no value is stored
+ ///
+ /// represents the name of the ping to retrieve the metric for.
+ /// Defaults to the first value in `sendInPings`
+ /// value of the stored metric
+ /// Thrown when the metric contains no value
+ public Int32 TestGetValue(string pingName = null)
+ {
+ Dispatchers.AssertInTestingMode();
+
+ if (!TestHasValue(pingName))
+ {
+ throw new NullReferenceException();
+ }
+
+ string ping = pingName ?? sendInPings[0];
+ return LibGleanFFI.glean_quantity_test_get_value(this.handle, ping);
+ }
+
+ ///
+ /// Returns the number of errors recorded for the given metric.
+ ///
+ /// the type of the error recorded.
+ /// represents the name of the ping to retrieve the metric for.
+ /// Defaults to the first value in `sendInPings`.
+ /// the number of errors recorded for the metric.
+ public Int32 TestGetNumRecordedErrors(Testing.ErrorType errorType, string pingName = null)
+ {
+ Dispatchers.AssertInTestingMode();
+
+ string ping = pingName ?? sendInPings[0];
+ return LibGleanFFI.glean_quantity_test_get_num_recorded_errors(
+ handle, (int)errorType, ping
+ );
+ }
+ }
+}
diff --git a/glean-core/csharp/GleanTests/Metrics/QuantityMetricTypeTest.cs b/glean-core/csharp/GleanTests/Metrics/QuantityMetricTypeTest.cs
new file mode 100644
index 0000000000..9318867a78
--- /dev/null
+++ b/glean-core/csharp/GleanTests/Metrics/QuantityMetricTypeTest.cs
@@ -0,0 +1,126 @@
+// 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/.
+
+using Mozilla.Glean.Testing;
+using System;
+using System.IO;
+using Xunit;
+using static Mozilla.Glean.Glean;
+
+namespace Mozilla.Glean.Tests.Metrics
+{
+ public class QuantityMetricTypeTest
+ {
+ public QuantityMetricTypeTest()
+ {
+ // Get a random test directory just for this single test.
+ string tempDataDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+
+ // In xUnit, the constructor will be called before each test. This
+ // feels like a natural place to initialize / reset Glean.
+ GleanInstance.Reset(
+ applicationId: "org.mozilla.csharp.tests",
+ applicationVersion: "1.0-test",
+ uploadEnabled: true,
+ configuration: new Configuration(),
+ dataDir: tempDataDir
+ );
+ }
+
+ [Fact]
+ public void APISavesToStorage()
+ {
+ Private.QuantityMetricType quantityMetric = new Private.QuantityMetricType(
+ category: "telemetry",
+ disabled: false,
+ lifetime: Private.Lifetime.Application,
+ name: "quantity_metric",
+ sendInPings: new string[] { "store1" }
+ );
+
+ Assert.False(quantityMetric.TestHasValue());
+
+ quantityMetric.Set(1);
+
+ // Check that the metric was properly recorded.
+ Assert.True(quantityMetric.TestHasValue());
+ Assert.Equal(1, quantityMetric.TestGetValue());
+
+ quantityMetric.Set(10);
+ // Check that the metric was properly overwritten.
+ Assert.True(quantityMetric.TestHasValue());
+ Assert.Equal(10, quantityMetric.TestGetValue());
+ }
+
+ [Fact]
+ public void DisabledCountersMustNotRecordData()
+ {
+ Private.QuantityMetricType quantityMetric = new Private.QuantityMetricType(
+ category: "telemetry",
+ disabled: true,
+ lifetime: Private.Lifetime.Application,
+ name: "quantity_metric",
+ sendInPings: new string[] { "store1" }
+ );
+
+ // Attempt to store the quantity.
+ quantityMetric.Set(1);
+ // Check that nothing was recorded.
+ Assert.False(quantityMetric.TestHasValue(), "Quantities must not be recorded if they are disabled");
+ }
+
+ [Fact]
+ public void TestGetValueThrows()
+ {
+ Private.QuantityMetricType quantityMetric = new Private.QuantityMetricType(
+ category: "telemetry",
+ disabled: true,
+ lifetime: Private.Lifetime.Application,
+ name: "quantity_metric",
+ sendInPings: new string[] { "store1" }
+ );
+ Assert.Throws(() => quantityMetric.TestGetValue());
+ }
+
+ [Fact]
+ public void APISavesToSecondaryPings()
+ {
+ Private.QuantityMetricType quantityMetric = new Private.QuantityMetricType(
+ category: "telemetry",
+ disabled: false,
+ lifetime: Private.Lifetime.Application,
+ name: "quantity_metric",
+ sendInPings: new string[] { "store1", "store2" }
+ );
+
+ quantityMetric.Set(1);
+
+ // Check that the metric was properly recorded for the secondary ping.
+ Assert.True(quantityMetric.TestHasValue("store2"));
+ Assert.Equal(1, quantityMetric.TestGetValue("store2"));
+
+ quantityMetric.Set(10);
+ // Check that the metric was properly overwritten for the secondary ping.
+ Assert.True(quantityMetric.TestHasValue("store2"));
+ Assert.Equal(10, quantityMetric.TestGetValue("store2"));
+ }
+
+ [Fact]
+ public void NegativeValuesAreNotRecorded()
+ {
+ Private.QuantityMetricType quantityMetric = new Private.QuantityMetricType(
+ category: "telemetry",
+ disabled: false,
+ lifetime: Private.Lifetime.Application,
+ name: "quantity_metric",
+ sendInPings: new string[] { "store1" }
+ );
+
+ quantityMetric.Set(-10);
+ // Check that quantity was NOT recorded.
+ Assert.False(quantityMetric.TestHasValue("store1"));
+ Assert.Equal(1, quantityMetric.TestGetNumRecordedErrors(ErrorType.InvalidValue));
+ }
+}
+}
diff --git a/glean-core/ios/Glean/Metrics/QuantityMetric.swift b/glean-core/ios/Glean/Metrics/QuantityMetric.swift
new file mode 100644
index 0000000000..1f74cb82dd
--- /dev/null
+++ b/glean-core/ios/Glean/Metrics/QuantityMetric.swift
@@ -0,0 +1,117 @@
+/* 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/. */
+
+import Foundation
+
+/// This implements the developer facing API for recording quantity metrics.
+///
+/// Instances of this class type are automatically generated by the parsers at build time,
+/// allowing developers to record values that were previously registered in the metrics.yaml file.
+///
+/// The quantity API only exposes the `QuantityMetricType.set(_:)` method, which takes care of validating the input
+/// data and making sure that requirements are enforced.
+public class QuantityMetricType {
+ let handle: UInt64
+ let disabled: Bool
+ let sendInPings: [String]
+
+ /// The public constructor used by automatically generated metrics.
+ public init(category: String, name: String, sendInPings: [String], lifetime: Lifetime, disabled: Bool) {
+ self.disabled = disabled
+ self.sendInPings = sendInPings
+ self.handle = withArrayOfCStrings(sendInPings) { pingArray in
+ glean_new_quantity_metric(
+ category,
+ name,
+ pingArray,
+ Int32(sendInPings.count),
+ lifetime.rawValue,
+ disabled.toByte()
+ )
+ }
+ }
+
+ /// An internal constructor to be used by the `LabeledMetricType` directly.
+ init(withHandle handle: UInt64, disabled: Bool, sendInPings: [String]) {
+ self.handle = handle
+ self.disabled = disabled
+ self.sendInPings = sendInPings
+ }
+
+ /// Destroy this metric.
+ deinit {
+ if self.handle != 0 {
+ glean_destroy_quantity_metric(self.handle)
+ }
+ }
+
+ /// Set a quantity value.
+ ///
+ /// - parameters:
+ /// * value: The value to set. Must be non-negative.
+ public func set(_ value: Int32) {
+ guard !self.disabled else { return }
+
+ Dispatchers.shared.launchAPI {
+ glean_quantity_set(self.handle, amount)
+ }
+ }
+
+ /// Tests whether a value is stored for the metric for testing purposes only. This function will
+ /// attempt to await the last task (if any) writing to the the metric's storage engine before
+ /// returning a value.
+ ///
+ /// - parameters:
+ /// * pingName: represents the name of the ping to retrieve the metric for.
+ /// Defaults to the first value in `sendInPings`.
+ /// - returns: true if metric value exists, otherwise false
+ public func testHasValue(_ pingName: String? = nil) -> Bool {
+ Dispatchers.shared.assertInTestingMode()
+
+ let pingName = pingName ?? self.sendInPings[0]
+ return glean_quantity_test_has_value(self.handle, pingName) != 0
+ }
+
+ /// Returns the stored value for testing purposes only. This function will attempt to await the
+ /// last task (if any) writing to the the metric's storage engine before returning a value.
+ ///
+ /// Throws a `String` exception if no value is stored
+ ///
+ /// - parameters:
+ /// * pingName: represents the name of the ping to retrieve the metric for.
+ /// Defaults to the first value in `sendInPings`.
+ ///
+ /// - returns: value of the stored metric
+ public func testGetValue(_ pingName: String? = nil) throws -> Int32 {
+ Dispatchers.shared.assertInTestingMode()
+
+ let pingName = pingName ?? self.sendInPings[0]
+
+ if !testHasValue(pingName) {
+ throw "Missing value"
+ }
+
+ return glean_quantity_test_get_value(self.handle, pingName)
+ }
+
+ /// Returns the number of errors recorded for the given metric.
+ ///
+ /// - parameters:
+ /// * errorType: The type of error recorded.
+ /// * pingName: represents the name of the ping to retrieve the metric for.
+ /// Defaults to the first value in `sendInPings`.
+ ///
+ /// - returns: The number of errors recorded for the metric for the given error type.
+ public func testGetNumRecordedErrors(_ errorType: ErrorType, pingName: String? = nil) -> Int32 {
+ Dispatchers.shared.assertInTestingMode()
+
+ let pingName = pingName ?? self.sendInPings[0]
+
+ return glean_quantity_test_get_num_recorded_errors(
+ self.handle,
+ errorType.rawValue,
+ pingName
+ )
+ }
+}
diff --git a/glean-core/ios/GleanTests/Metrics/QuantityMetricTypeTest.swift b/glean-core/ios/GleanTests/Metrics/QuantityMetricTypeTest.swift
new file mode 100644
index 0000000000..9f7ed79504
--- /dev/null
+++ b/glean-core/ios/GleanTests/Metrics/QuantityMetricTypeTest.swift
@@ -0,0 +1,110 @@
+/* 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/. */
+
+@testable import Glean
+import XCTest
+
+// swiftlint:disable force_cast
+// REASON: Used in a test
+class QuantityMetricTypeTests: XCTestCase {
+ override func setUp() {
+ Glean.shared.resetGlean(clearStores: true)
+ }
+
+ func testCounterSavesToStorage() {
+ let quantityMetric = QuantityMetricType(
+ category: "telemetry",
+ name: "quantity_metric",
+ sendInPings: ["store1"],
+ lifetime: .application,
+ disabled: false
+ )
+
+ XCTAssertFalse(quantityMetric.testHasValue())
+
+ quantityMetric.set(1)
+
+ // Check that the metric was properly recorded.
+ XCTAssert(quantityMetric.testHasValue())
+ XCTAssertEqual(1, try quantityMetric.testGetValue())
+
+ quantityMetric.set(10)
+ // Check that the metric was properly overwritten.
+ XCTAssert(quantityMetric.testHasValue())
+ XCTAssertEqual(10, try quantityMetric.testGetValue())
+ }
+
+ func testCounterMustNotRecordIfDisabled() {
+ let quantityMetric = QuantityMetricType(
+ category: "telemetry",
+ name: "quantity_metric",
+ sendInPings: ["store1"],
+ lifetime: .application,
+ disabled: true
+ )
+
+ XCTAssertFalse(quantityMetric.testHasValue())
+
+ quantityMetric.add(1)
+
+ XCTAssertFalse(quantityMetric.testHasValue(), "Quantities must not be recorded if they are disabled")
+ }
+
+ func testCounterGetValueThrowsExceptionIfNothingIsStored() {
+ let quantityMetric = QuantityMetricType(
+ category: "telemetry",
+ name: "quantity_metric",
+ sendInPings: ["store1"],
+ lifetime: .application,
+ disabled: false
+ )
+
+ XCTAssertThrowsError(try quantityMetric.testGetValue()) { error in
+ XCTAssertEqual(error as! String, "Missing value")
+ }
+ }
+
+ func testCounterSavesToSecondaryPings() {
+ let quantityMetric = QuantityMetricType(
+ category: "telemetry",
+ name: "quantity_metric",
+ sendInPings: ["store1", "store2"],
+ lifetime: .application,
+ disabled: false
+ )
+
+ quantityMetric.set(1)
+
+ // Check that the metric was properly recorded.
+ XCTAssert(quantityMetric.testHasValue("store2"))
+ XCTAssertEqual(1, try quantityMetric.testGetValue("store2"))
+
+ quantityMetric.set(10)
+ // Check that the metric was properly overwritten.
+ XCTAssert(quantityMetric.testHasValue("store2"))
+ XCTAssertEqual(10, try quantityMetric.testGetValue("store2"))
+ }
+
+ func testNegativeValuesAreNotCounted() {
+ let quantityMetric = QuantityMetricType(
+ category: "telemetry",
+ name: "quantity_metric",
+ sendInPings: ["store1", "store2"],
+ lifetime: .application,
+ disabled: false
+ )
+
+ quantityMetric.set(1)
+
+ // Check that the metric was properly recorded.
+ XCTAssert(quantityMetric.testHasValue("store1"))
+ XCTAssertEqual(1, try quantityMetric.testGetValue("store1"))
+
+ quantityMetric.set(-10)
+ // Check that the metric was NOT recorded.
+ XCTAssert(quantityMetric.testHasValue("store1"))
+ XCTAssertEqual(1, try quantityMetric.testGetValue("store1"))
+ XCTAssertEqual(1, quantityMetric.testGetNumRecordedErrors(.invalidValue))
+ }
+}
diff --git a/glean-core/python/glean/_loader.py b/glean-core/python/glean/_loader.py
index fbff633994..1b9d4efcb5 100644
--- a/glean-core/python/glean/_loader.py
+++ b/glean-core/python/glean/_loader.py
@@ -40,6 +40,8 @@
"timespan": metrics.TimespanMetricType,
"timing_distribution": metrics.TimingDistributionMetricType,
"uuid": metrics.UuidMetricType,
+ "jwe": metrics.JweMetricType,
+ "quantity": metrics.QuantityMetricType,
}
diff --git a/glean-core/python/glean/metrics/__init__.py b/glean-core/python/glean/metrics/__init__.py
index 35d924621c..e6b87d41d1 100644
--- a/glean-core/python/glean/metrics/__init__.py
+++ b/glean-core/python/glean/metrics/__init__.py
@@ -13,6 +13,7 @@
from .datetime import DatetimeMetricType
from .event import EventMetricType, RecordedEventData
from .experiment import RecordedExperimentData
+from .quantity import QuantityMetricType
from .jwe import JweMetricType
from .labeled import (
LabeledBooleanMetricType,
@@ -37,6 +38,7 @@
"DatetimeMetricType",
"EventMetricType",
"JweMetricType",
+ "QuantityMetricType",
"LabeledBooleanMetricType",
"LabeledCounterMetricType",
"LabeledStringMetricType",
diff --git a/glean-core/python/glean/metrics/quantity.py b/glean-core/python/glean/metrics/quantity.py
new file mode 100644
index 0000000000..eddbc4b531
--- /dev/null
+++ b/glean-core/python/glean/metrics/quantity.py
@@ -0,0 +1,135 @@
+# 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/.
+
+
+from typing import List, Optional
+
+
+from .. import _ffi
+from .._dispatcher import Dispatcher
+from ..testing import ErrorType
+
+
+from .lifetime import Lifetime
+
+
+class QuantityMetricType:
+ """
+ This implements the developer facing API for recording quantity metrics.
+
+ Instances of this class type are automatically generated by
+ `glean.load_metrics`, allowing developers to record values that were
+ previously registered in the metrics.yaml file.
+
+ The quantity API only exposes the `QuantityMetricType.set` method, which
+ takes care of validating the input data and making sure that limits are
+ enforced.
+ """
+
+ def __init__(
+ self,
+ disabled: bool,
+ category: str,
+ lifetime: Lifetime,
+ name: str,
+ send_in_pings: List[str],
+ ):
+ self._disabled = disabled
+ self._send_in_pings = send_in_pings
+
+ self._handle = _ffi.lib.glean_new_quantity_metric(
+ _ffi.ffi_encode_string(category),
+ _ffi.ffi_encode_string(name),
+ _ffi.ffi_encode_vec_string(send_in_pings),
+ len(send_in_pings),
+ lifetime.value,
+ disabled,
+ )
+
+ def __del__(self):
+ if getattr(self, "_handle", 0) != 0:
+ _ffi.lib.glean_destroy_quantity_metric(self._handle)
+
+ def set(self, value: int) -> None:
+ """
+ Set a quantity value.
+
+ Args:
+ value (int): The value to set. Must be non-negative.
+ """
+ if self._disabled:
+ return
+
+ @Dispatcher.launch
+ def set():
+ _ffi.lib.glean_quantity_set(self._handle, value)
+
+ def test_has_value(self, ping_name: Optional[str] = None) -> bool:
+ """
+ Tests whether a value is stored for the metric for testing purposes
+ only.
+
+ Args:
+ ping_name (str): (default: first value in send_in_pings) The name
+ of the ping to retrieve the metric for.
+
+ Returns:
+ has_value (bool): True if the metric value exists.
+ """
+ if ping_name is None:
+ ping_name = self._send_in_pings[0]
+
+ return bool(
+ _ffi.lib.glean_quantity_test_has_value(
+ self._handle, _ffi.ffi_encode_string(ping_name)
+ )
+ )
+
+ def test_get_value(self, ping_name: Optional[str] = None) -> int:
+ """
+ Returns the stored value for testing purposes only.
+
+ Args:
+ ping_name (str): (default: first value in send_in_pings) The name
+ of the ping to retrieve the metric for.
+
+ Returns:
+ value (int): value of the stored metric.
+ """
+ if ping_name is None:
+ ping_name = self._send_in_pings[0]
+
+ if not self.test_has_value(ping_name):
+ raise ValueError("metric has no value")
+
+ return _ffi.lib.glean_quantity_test_get_value(
+ self._handle, _ffi.ffi_encode_string(ping_name)
+ )
+
+ def test_get_num_recorded_errors(
+ self, error_type: ErrorType, ping_name: Optional[str] = None
+ ) -> int:
+ """
+ Returns the number of errors recorded for the given metric.
+
+ Args:
+ error_type (ErrorType): The type of error recorded.
+ ping_name (str): (default: first value in send_in_pings) The name
+ of the ping to retrieve the metric for.
+
+ Returns:
+ num_errors (int): The number of errors recorded for the metric for
+ the given error type.
+ """
+ if ping_name is None:
+ ping_name = self._send_in_pings[0]
+
+ return _ffi.lib.glean_quantity_test_get_num_recorded_errors(
+ self._handle,
+ error_type.value,
+ _ffi.ffi_encode_string(ping_name),
+ )
+
+
+__all__ = ["QuantityMetricType"]
diff --git a/glean-core/python/tests/metrics/test_quantity.py b/glean-core/python/tests/metrics/test_quantity.py
new file mode 100644
index 0000000000..beac476fe8
--- /dev/null
+++ b/glean-core/python/tests/metrics/test_quantity.py
@@ -0,0 +1,115 @@
+# 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/.
+
+
+import pytest
+
+
+from glean import metrics
+from glean.metrics import Lifetime
+from glean import testing
+
+
+def test_the_api_saves_to_its_storage_engine():
+ # Define a quantity metric, which will be stored in "store1"
+ quantity_metric = metrics.QuantityMetricType(
+ disabled=False,
+ category="telemetry",
+ lifetime=Lifetime.APPLICATION,
+ name="quantity_metric",
+ send_in_pings=["store1"],
+ )
+
+ assert quantity_metric.test_has_value() is False
+
+ quantity_metric.set(1)
+
+ # Check that the metric was properly recorded
+ assert quantity_metric.test_has_value() is True
+ assert 1 == quantity_metric.test_get_value()
+
+ quantity_metric.set(10)
+
+ # Check that the metric was properly overwritten
+ assert quantity_metric.test_has_value() is True
+ assert 10 == quantity_metric.test_get_value()
+
+
+def test_disabled_quantities_must_not_record_data():
+ # Define a quantity metric, which will be stored in "store1"
+ quantity_metric = metrics.QuantityMetricType(
+ disabled=True,
+ category="telemetry",
+ lifetime=Lifetime.APPLICATION,
+ name="quantity_metric",
+ send_in_pings=["store1"],
+ )
+
+ # Attempt to increment the quantity
+ quantity_metric.set(1)
+ # Check that nothing was recorded
+ assert quantity_metric.test_has_value() is False
+
+
+def test_get_value_throws_value_error_if_nothing_is_stored():
+ # Define a quantity metric, which will be stored in "store1"
+ quantity_metric = metrics.QuantityMetricType(
+ disabled=True,
+ category="telemetry",
+ lifetime=Lifetime.APPLICATION,
+ name="quantity_metric",
+ send_in_pings=["store1"],
+ )
+
+ with pytest.raises(ValueError):
+ quantity_metric.test_get_value()
+
+
+def test_api_saves_to_secondary_pings():
+ # Define a quantity metric, which will be stored in "store1" and "store2"
+ quantity_metric = metrics.QuantityMetricType(
+ disabled=False,
+ category="telemetry",
+ lifetime=Lifetime.APPLICATION,
+ name="quantity_metric",
+ send_in_pings=["store1", "store2"],
+ )
+
+ quantity_metric.set(1)
+
+ # Check that the metric was properly recorded on the second ping
+ assert quantity_metric.test_has_value("store2")
+ assert 1 == quantity_metric.test_get_value("store2")
+
+ quantity_metric.set(10)
+
+ # Check that the metric was properly overwritten on the second ping
+ assert quantity_metric.test_has_value("store2")
+ assert 10 == quantity_metric.test_get_value("store2")
+
+
+def test_negative_values_are_not_counted():
+ # Define a quantity metric, which will be stored in "store1"
+ quantity_metric = metrics.QuantityMetricType(
+ disabled=False,
+ category="telemetry",
+ lifetime=Lifetime.APPLICATION,
+ name="quantity_metric",
+ send_in_pings=["store1"],
+ )
+
+ quantity_metric.set(1)
+
+ # Check that the metric was properly recorded
+ assert quantity_metric.test_has_value("store1")
+ assert 1 == quantity_metric.test_get_value("store1")
+
+ quantity_metric.set(-10)
+
+ # Check that the quantity was NOT recorded
+ assert quantity_metric.test_has_value("store1")
+ assert 1 == quantity_metric.test_get_value("store1")
+ assert 1 == quantity_metric.test_get_num_recorded_errors(
+ testing.ErrorType.INVALID_VALUE
+ )