From fb940eb841a97dcca45c8c9f9ffc7c98d552e0b7 Mon Sep 17 00:00:00 2001 From: Jan-Erik Rediger Date: Mon, 3 Aug 2026 17:02:03 +0200 Subject: [PATCH] Implement the custom distribution for iOS and Python --- CHANGELOG.md | 4 + .../reference/metrics/custom_distribution.md | 106 ++++++++++++++-- .../ios/Glean.xcodeproj/project.pbxproj | 8 ++ .../Metrics/CustomDistributionMetric.swift | 20 +++ .../CustomDistributionMetricTests.swift | 115 ++++++++++++++++++ glean-core/python/glean/_loader.py | 1 + glean-core/python/glean/metrics/__init__.py | 6 +- .../tests/metrics/test_custom_distribution.py | 103 ++++++++++++++++ samples/ios/app/metrics.yaml | 36 ++++++ 9 files changed, 390 insertions(+), 9 deletions(-) create mode 100644 glean-core/ios/Glean/Metrics/CustomDistributionMetric.swift create mode 100644 glean-core/ios/GleanTests/Metrics/CustomDistributionMetricTests.swift create mode 100644 glean-core/python/tests/metrics/test_custom_distribution.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f75be40b8..2556cee6a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ * Implement glean-noop as a feature of glean-sym ([#3541](https://github.com/mozilla/glean/pull/3541)) * Support pings ([#3544](https://github.com/mozilla/glean/pull/3544)) * Implement the event metric ([#3534](https://github.com/mozilla/glean/pull/3534)) +* iOS + * Implement the custom distribution metric type ([#3572](https://github.com/mozilla/glean/pull/3572)) +* Python + * Implement the custom distribution metric type ([#3572](https://github.com/mozilla/glean/pull/3572)) # v69.0.0 (2026-06-22) diff --git a/docs/user/reference/metrics/custom_distribution.md b/docs/user/reference/metrics/custom_distribution.md index 92be63e5ae..4e8c4d7ab7 100644 --- a/docs/user/reference/metrics/custom_distribution.md +++ b/docs/user/reference/metrics/custom_distribution.md @@ -37,8 +37,25 @@ Graphics.INSTANCE.checkerboardPeak().accumulateSamples(listOf(23)); ``` -
-
+
+ +```Swift +import Glean + +Graphics.checkerboardPeak.accumulateSamples([23]) +``` + +
+
+ +```Python +from glean import load_metrics +metrics = load_metrics("metrics.yaml") + +metrics.graphics.checkerboard_peak.accumulate_samples([23]) +``` + +
```Rust @@ -111,8 +128,25 @@ Graphics.INSTANCE.checkerboardPeak().accumulateSingleSample(23); ```
-
-
+
+ +```Swift +import Glean + +Graphics.checkerboardPeak.accumulateSingleSample(23) +``` + +
+
+ +```Python +from glean import load_metrics +metrics = load_metrics("metrics.yaml") + +metrics.graphics.checkerboard_peak.accumulate_single_sample(23) +``` + +
```Rust @@ -258,8 +292,42 @@ assertEquals(1L, snapshot.count); ```
-
-
+
+ +```Swift +import Glean + +// Get snapshot +let snapshot = try! Graphics.checkerboardPeak.testGetValue() + +// Does the sum have the expected value? +XCTAssertEqual(23, snapshot.sum) + +// Does the count have the expected value? +XCTAssertEqual(1, snapshot.count) + +// Buckets are indexed by their lower bound. +XCTAssertEqual(1L, snapshot.values[19]) +``` + +
+
+ +```Python +from glean import load_metrics +metrics = load_metrics("metrics.yaml") + +# Does the sum have the expected value? +assert 23 == metrics.graphics.checkerboard_peak.test_get_value().sum + +# Does the count have the expected value? +assert 1 == metrics.graphics.checkerboard_peak.test_get_value().count + +# Buckets are indexed by their lower bound. +assert 1 == metrics.graphics.checkerboard_peak.test_get_value().values[19] +``` + +
```Rust @@ -349,8 +417,30 @@ assertEquals( ```
-
-
+
+ +```Swift +import Glean + +/// Did the metric receive a negative value? +XCTAssertEqual( + 0, + Graphics.checkerboardPeak.testGetNumRecordedErrors(.invalidValue) +) +``` + +
+
+ +```Python +from glean import load_metrics +metrics = load_metrics("metrics.yaml") + +# Were any of the values negative and thus caused an error to be recorded? +assert 0 == metrics.graphics.checkerboard_peak.test_get_num_recorded_errors(ErrorType.INVALID_VALUE) +``` + +
```Rust diff --git a/glean-core/ios/Glean.xcodeproj/project.pbxproj b/glean-core/ios/Glean.xcodeproj/project.pbxproj index a35a4b3280..4369ad1e90 100644 --- a/glean-core/ios/Glean.xcodeproj/project.pbxproj +++ b/glean-core/ios/Glean.xcodeproj/project.pbxproj @@ -71,6 +71,8 @@ CD70CF932850D69500FC2014 /* Gzip in Frameworks */ = {isa = PBXBuildFile; productRef = CD70CF922850D69500FC2014 /* Gzip */; }; CD70CF982850D77200FC2014 /* OHHTTPStubs in Frameworks */ = {isa = PBXBuildFile; productRef = CD70CF972850D77200FC2014 /* OHHTTPStubs */; }; CD70CF9A2850D79200FC2014 /* OHHTTPStubsSwift in Frameworks */ = {isa = PBXBuildFile; productRef = CD70CF992850D79200FC2014 /* OHHTTPStubsSwift */; }; + CD72C3C03020E355009B6DCF /* CustomDistributionMetric.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD72C3BF3020E355009B6DCF /* CustomDistributionMetric.swift */; }; + CD72C3C23020E3FF009B6DCF /* CustomDistributionMetricTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD72C3C13020E3FF009B6DCF /* CustomDistributionMetricTests.swift */; }; CD81DCFA282A8F9B00347965 /* RateMetric.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD81DCF9282A8F9A00347965 /* RateMetric.swift */; }; CD81DCFC282A911400347965 /* RateMetricTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD81DCFB282A911400347965 /* RateMetricTests.swift */; }; CD9DA7852BC809BE00E18F31 /* ObjectMetricTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD9DA7842BC809BE00E18F31 /* ObjectMetricTests.swift */; }; @@ -162,6 +164,8 @@ CD3682F22CAC10FE00B02F04 /* RidealongPingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RidealongPingTests.swift; sourceTree = ""; }; CD387868271D9CD100C097D8 /* glean.udl */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; name = glean.udl; path = ../../src/glean.udl; sourceTree = ""; }; CD38786C271DCCC700C097D8 /* libglean_ffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libglean_ffi.a; path = ../../target/libglean_ffi.a; sourceTree = ""; }; + CD72C3BF3020E355009B6DCF /* CustomDistributionMetric.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDistributionMetric.swift; sourceTree = ""; }; + CD72C3C13020E3FF009B6DCF /* CustomDistributionMetricTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDistributionMetricTests.swift; sourceTree = ""; }; CD81DCF9282A8F9A00347965 /* RateMetric.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RateMetric.swift; sourceTree = ""; }; CD81DCFB282A911400347965 /* RateMetricTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RateMetricTests.swift; sourceTree = ""; }; CD9DA7842BC809BE00E18F31 /* ObjectMetricTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObjectMetricTests.swift; sourceTree = ""; }; @@ -347,6 +351,7 @@ AC06529B26E032E300D92D5E /* QuantityMetric.swift */, 1F6A8FEF233C049D007837D5 /* BooleanMetric.swift */, BF43A8C6232A4BA400545310 /* CounterMetric.swift */, + CD72C3BF3020E355009B6DCF /* CustomDistributionMetric.swift */, CD81DCF9282A8F9A00347965 /* RateMetric.swift */, 1F6A8FF3233C0A91007837D5 /* DatetimeMetric.swift */, BF6C53B1232F870C00E3B43A /* Ping.swift */, @@ -371,6 +376,7 @@ AC06529D26E034BF00D92D5E /* QuantityMetricTypeTest.swift */, 1F6A8FF1233C068A007837D5 /* BooleanMetricTypeTest.swift */, BF43A8CC232A615200545310 /* CounterMetricTests.swift */, + CD72C3C13020E3FF009B6DCF /* CustomDistributionMetricTests.swift */, CD81DCFB282A911400347965 /* RateMetricTests.swift */, 1F6A8FF5233C1555007837D5 /* DatetimeMetricTypeTests.swift */, BF6C53B3232F872B00E3B43A /* PingTests.swift */, @@ -638,6 +644,7 @@ CDBFB4DC27C3FA520045CCB9 /* Dispatchers.swift in Sources */, EDC21C8F2EE22CCB0042D53E /* GleanUploadTaskProvider.swift in Sources */, 1F6058932314863400307A9F /* Configuration.swift in Sources */, + CD72C3C03020E355009B6DCF /* CustomDistributionMetric.swift in Sources */, BF2E57052334B77D00364D92 /* EventMetric.swift in Sources */, BF10008023548B0500064051 /* MemoryDistributionMetric.swift in Sources */, BF93C698224BFC57006CE7D8 /* Glean.swift in Sources */, @@ -681,6 +688,7 @@ 8AF3BEA12E60EC670007A9ED /* PingUploaderTests.swift in Sources */, BFAED50A2369752400DF293D /* StringListMetricTests.swift in Sources */, 60691AEB28DD0BF200BDF31A /* BaselinePingTests.swift in Sources */, + CD72C3C23020E3FF009B6DCF /* CustomDistributionMetricTests.swift in Sources */, BF890561232BC227003CA2BA /* StringMetricTests.swift in Sources */, CD0F7CC226F0F28900EDA6A4 /* UrlMetricTests.swift in Sources */, EDC21B942EE20B2C0042D53E /* PingUploadSchedulerTests.swift in Sources */, diff --git a/glean-core/ios/Glean/Metrics/CustomDistributionMetric.swift b/glean-core/ios/Glean/Metrics/CustomDistributionMetric.swift new file mode 100644 index 0000000000..decf6cee84 --- /dev/null +++ b/glean-core/ios/Glean/Metrics/CustomDistributionMetric.swift @@ -0,0 +1,20 @@ +/* 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/. */ + +/// This implements the developer facing API for recording custom distribution metrics. +/// +/// Custom distributions are histograms with the following parameters that are settable on a +/// per-metric basis: +/// +/// - `rangeMin`/`rangeMax`: The minimum and maximum values +/// - `bucketCount`: The number of histogram buckets +/// - `histogramType`: Whether the bucketing is linear or exponential +/// +/// This metric exists primarily for backward compatibility with histograms in +/// legacy (pre-Glean) telemetry, and its use is not recommended for newly-created +/// 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. +public typealias CustomDistributionMetricType = CustomDistributionMetric diff --git a/glean-core/ios/GleanTests/Metrics/CustomDistributionMetricTests.swift b/glean-core/ios/GleanTests/Metrics/CustomDistributionMetricTests.swift new file mode 100644 index 0000000000..ecd34614c9 --- /dev/null +++ b/glean-core/ios/GleanTests/Metrics/CustomDistributionMetricTests.swift @@ -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/. */ + +@testable import Glean +import XCTest + +class CustomDistributionTypeTests: XCTestCase { + override func setUp() { + resetGleanDiscardingInitialPings(testCase: self, tag: "CustomDistributionTypeTests") + } + + override func tearDown() { + tearDownStubs() + } + + func testTiminingDistributionSavesToStorage() { + let metric = CustomDistributionMetricType(CommonMetricData( + category: "telemetry", + name: "custom_distribution", + sendInPings: ["store1"], + lifetime: .ping, + disabled: false + ), + 0, + 100, + 100, + .linear + ) + + // Accumulate a few values + metric.accumulateSamples([1, 2, 3]) + + // Check that data was properly recorded. + // We can only check the count, as we don't control the time. + let snapshot = metric.testGetValue()! + let sum = snapshot.values.values.reduce(0, +) + XCTAssertEqual(3, sum) + + // Check the sum + XCTAssertEqual(1 + 2 + 3, snapshot.sum) + // Check that the 1L fell into the first value bucket + XCTAssertEqual(1, snapshot.values[1]) + // Check that the 2L fell into the second value bucket + XCTAssertEqual(1, snapshot.values[2]) + // Check that the 3L fell into the third value bucket + XCTAssertEqual(1, snapshot.values[3]) + } + + func testCustomDistributionMustNotRecordIfDisabled() { + let metric = CustomDistributionMetricType(CommonMetricData( + category: "telemetry", + name: "custom_distribution", + sendInPings: ["store1"], + lifetime: .ping, + disabled: true + ), 0, 100, 100, .linear + ) + + metric.accumulateSamples([1]) + XCTAssertNil(metric.testGetValue()) + } + + func testCustomDistributionGetValueReturnsNilIfNothingIsStored() { + let metric = CustomDistributionMetricType(CommonMetricData( + category: "telemetry", + name: "custom_distribution", + sendInPings: ["store1"], + lifetime: .application, + disabled: false + ), 0, 100, 100, .linear + ) + + XCTAssertNil(metric.testGetValue()) + } + + func testCustomDistributionSavesToSecondaryPings() { + // Define a custom distribution metric which will be stored in multiple stores + let metric = CustomDistributionMetricType(CommonMetricData( + category: "telemetry", + name: "custom_distribution", + sendInPings: ["store1", "store2", "store3"], + lifetime: .application, + disabled: false + ), 0, 100, 100, .linear + ) + + // Accumulate a few values + metric.accumulateSamples([1, 2, 3]) + + // Check that data was properly recorded in the second ping. + var snapshot = metric.testGetValue("store2")! + + // Check the sum + XCTAssertEqual(1+2+3, snapshot.sum) + // Check that the 1L fell into the first value bucket + XCTAssertEqual(1, snapshot.values[1]) + // Check that the 2L fell into the second value bucket + XCTAssertEqual(1, snapshot.values[2]) + // Check that the 3L fell into the third value bucket + XCTAssertEqual(1, snapshot.values[3]) + + // Check that data was properly recorded in the second ping. + snapshot = metric.testGetValue("store3")! + + // Check the sum + XCTAssertEqual(1+2+3, snapshot.sum) + // Check that the 1L fell into the first value bucket + XCTAssertEqual(1, snapshot.values[1]) + // Check that the 2L fell into the second value bucket + XCTAssertEqual(1, snapshot.values[2]) + // Check that the 3L fell into the third value bucket + XCTAssertEqual(1, snapshot.values[3]) + } +} diff --git a/glean-core/python/glean/_loader.py b/glean-core/python/glean/_loader.py index ca50f388cb..146c80d8f9 100644 --- a/glean-core/python/glean/_loader.py +++ b/glean-core/python/glean/_loader.py @@ -42,6 +42,7 @@ "timespan": metrics.TimespanMetricType, "timing_distribution": metrics.TimingDistributionMetricType, "uuid": metrics.UuidMetricType, + "custom_distribution": metrics.CustomDistributionMetricType, } diff --git a/glean-core/python/glean/metrics/__init__.py b/glean-core/python/glean/metrics/__init__.py index 4644589d1e..e3fab36301 100644 --- a/glean-core/python/glean/metrics/__init__.py +++ b/glean-core/python/glean/metrics/__init__.py @@ -11,16 +11,18 @@ from .._uniffi import AttributionMetrics from .._uniffi import CommonMetricData from .._uniffi import DistributionMetrics +from .._uniffi import HistogramType from .._uniffi import LabeledMetricData from .._uniffi import Lifetime from .._uniffi import MemoryUnit +from .._uniffi import RecordedExperiment from .._uniffi import TimerId from .._uniffi import TimeUnit -from .._uniffi import RecordedExperiment # Re-export some metrics directly from .._uniffi import BooleanMetric as BooleanMetricType from .._uniffi import CounterMetric as CounterMetricType +from .._uniffi import CustomDistributionMetric as CustomDistributionMetricType from .._uniffi import DenominatorMetric as DenominatorMetricType from .._uniffi import MemoryDistributionMetric as MemoryDistributionMetricType from .._uniffi import NumeratorMetric as NumeratorMetricType @@ -52,6 +54,7 @@ "BooleanMetricType", "CommonMetricData", "CounterMetricType", + "CustomDistributionMetricType", "DatetimeMetricType", "DenominatorMetricType", "DistributionMetrics", @@ -82,4 +85,5 @@ "TimingDistributionMetricType", "UrlMetricType", "UuidMetricType", + "HistogramType", ] diff --git a/glean-core/python/tests/metrics/test_custom_distribution.py b/glean-core/python/tests/metrics/test_custom_distribution.py new file mode 100644 index 0000000000..cd51ec0246 --- /dev/null +++ b/glean-core/python/tests/metrics/test_custom_distribution.py @@ -0,0 +1,103 @@ +# 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 glean import metrics +from glean.metrics import Lifetime, CommonMetricData, HistogramType + + +def test_the_api_saves_to_its_storage_engine(): + metric = metrics.CustomDistributionMetricType( + CommonMetricData( + disabled=False, + category="telemetry", + lifetime=Lifetime.APPLICATION, + name="custom_distribution", + send_in_pings=["store1"], + label=None, + ), + range_min=0, + range_max=100, + bucket_count=100, + histogram_type=HistogramType.LINEAR, + ) + + metric.accumulate_samples([1, 2]) + metric.accumulate_single_sample(3) + + snapshot = metric.test_get_value() + assert 1 + 2 + 3 == snapshot.sum + assert 1 == snapshot.values[1] + assert 1 == snapshot.values[2] + assert 1 == snapshot.values[3] + + +def test_exponential_distribution(): + metric = metrics.CustomDistributionMetricType( + CommonMetricData( + disabled=False, + category="telemetry", + lifetime=Lifetime.APPLICATION, + name="custom_distribution", + send_in_pings=["store1"], + label=None, + ), + range_min=0, + range_max=100, + bucket_count=10, + histogram_type=HistogramType.EXPONENTIAL, + ) + + metric.accumulate_samples([1, 20, 50]) + + snapshot = metric.test_get_value() + assert 1 + 20 + 50 == snapshot.sum + print(snapshot) + assert 1 == snapshot.values[1] + assert 1 == snapshot.values[16] + assert 1 == snapshot.values[29] + + +def test_get_value_throws_if_nothing_is_stored(): + metric = metrics.CustomDistributionMetricType( + CommonMetricData( + disabled=False, + category="telemetry", + lifetime=Lifetime.APPLICATION, + name="custom_distribution", + send_in_pings=["store1"], + label=None, + ), + range_min=0, + range_max=100, + bucket_count=100, + histogram_type=HistogramType.LINEAR, + ) + + assert not metric.test_get_value() + + +def test_the_api_saves_to_secondary_pings(): + metric = metrics.CustomDistributionMetricType( + CommonMetricData( + disabled=False, + category="telemetry", + lifetime=Lifetime.APPLICATION, + name="custom_distribution", + send_in_pings=["store1", "store2", "store3"], + label=None, + ), + range_min=0, + range_max=100, + bucket_count=100, + histogram_type=HistogramType.LINEAR, + ) + + metric.accumulate_samples(list(range(1, 4))) + + for store in ["store1", "store2", "store3"]: + snapshot = metric.test_get_value(store) + assert 1 + 2 + 3 == snapshot.sum + assert 1 == snapshot.values[1] + assert 1 == snapshot.values[2] + assert 1 == snapshot.values[3] diff --git a/samples/ios/app/metrics.yaml b/samples/ios/app/metrics.yaml index 405689252c..fea4eea065 100644 --- a/samples/ios/app/metrics.yaml +++ b/samples/ios/app/metrics.yaml @@ -265,3 +265,39 @@ party: type: array items: type: number + +# These remain unused in the sample app, +# but it's a good way to ensure the code that is generated for them compiles. +codegen_test: + counter: &defaults + type: counter + description: | + A metric for codegen testing -- + no extra data, just setting the defaults + bugs: + - https://bugzilla.mozilla.org/TODO + data_reviews: + - http://example.com/reviews + notification_emails: + - CHANGE-ME@example.com + expires: never + send_in_pings: + - sample + + timing_distribution: + <<: *defaults + type: timing_distribution + time_unit: millisecond + + memory_distribution: + <<: *defaults + type: memory_distribution + memory_unit: byte + + custom_distribution: + <<: *defaults + type: custom_distribution + range_min: 0 + range_max: 100 + bucket_count: 100 + histogram_type: linear