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