feat(shorebird_code_push_protocol): add unique-users metric API surface (#3814)

This commit is contained in:
Mac
2026-06-12 11:04:55 -06:00
committed by GitHub
parent 86842b538a
commit 1ad5bd0939
13 changed files with 611 additions and 0 deletions
@@ -35,6 +35,7 @@ export 'package:shorebird_code_push_protocol/src/messages/get_release/get_releas
export 'package:shorebird_code_push_protocol/src/messages/get_release_artifacts/get_release_artifacts_response.dart';
export 'package:shorebird_code_push_protocol/src/messages/get_release_patches/get_release_patches_response.dart';
export 'package:shorebird_code_push_protocol/src/messages/get_releases/get_releases_response.dart';
export 'package:shorebird_code_push_protocol/src/messages/get_unique_users/get_unique_users_response.dart';
export 'package:shorebird_code_push_protocol/src/messages/get_version_distribution/get_version_distribution_response.dart';
export 'package:shorebird_code_push_protocol/src/messages/patch_check/patch_check_request.dart';
export 'package:shorebird_code_push_protocol/src/messages/patch_check/patch_check_response.dart';
@@ -48,6 +49,8 @@ export 'package:shorebird_code_push_protocol/src/models/app_collaborator_role.da
export 'package:shorebird_code_push_protocol/src/models/app_metadata.dart';
export 'package:shorebird_code_push_protocol/src/models/channel.dart';
export 'package:shorebird_code_push_protocol/src/models/get_patch_adoption_parameter3.dart';
export 'package:shorebird_code_push_protocol/src/models/get_unique_users_parameter2.dart';
export 'package:shorebird_code_push_protocol/src/models/get_unique_users_parameter3.dart';
export 'package:shorebird_code_push_protocol/src/models/latest_release.dart';
export 'package:shorebird_code_push_protocol/src/models/organization.dart';
export 'package:shorebird_code_push_protocol/src/models/organization_membership.dart';
@@ -69,6 +72,9 @@ export 'package:shorebird_code_push_protocol/src/models/release_patch.dart';
export 'package:shorebird_code_push_protocol/src/models/release_platform.dart';
export 'package:shorebird_code_push_protocol/src/models/release_status.dart';
export 'package:shorebird_code_push_protocol/src/models/role.dart';
export 'package:shorebird_code_push_protocol/src/models/unique_users_breakdown_entry.dart';
export 'package:shorebird_code_push_protocol/src/models/unique_users_range.dart';
export 'package:shorebird_code_push_protocol/src/models/unique_users_time_series_entry.dart';
export 'package:shorebird_code_push_protocol/src/models/version_distribution_entry.dart';
/// Parsed JSON data.
@@ -0,0 +1,114 @@
import 'package:meta/meta.dart';
import 'package:shorebird_code_push_protocol/model_helpers.dart';
import 'package:shorebird_code_push_protocol/src/models/unique_users_breakdown_entry.dart';
import 'package:shorebird_code_push_protocol/src/models/unique_users_range.dart';
import 'package:shorebird_code_push_protocol/src/models/unique_users_time_series_entry.dart';
/// {@template get_unique_users_response}
/// The response body for GET /apps/{appId}/metrics/unique-users.
/// {@endtemplate}
@immutable
class GetUniqueUsersResponse {
/// {@macro get_unique_users_response}
const GetUniqueUsersResponse({
required this.uniqueUsers,
required this.granularity,
required this.range,
required this.asOf,
this.timeSeries,
this.breakdown,
});
/// Converts a `Map<String, dynamic>` to a [GetUniqueUsersResponse].
factory GetUniqueUsersResponse.fromJson(Map<String, dynamic> json) {
return parseFromJson(
'GetUniqueUsersResponse',
json,
() => GetUniqueUsersResponse(
uniqueUsers: json['unique_users'] as int,
granularity: checkedKey(json, 'granularity') as String?,
range: UniqueUsersRange.fromJson(json['range'] as Map<String, dynamic>),
asOf: DateTime.parse(json['as_of'] as String),
timeSeries: (json['time_series'] as List?)
?.map<UniqueUsersTimeSeriesEntry>(
(e) => UniqueUsersTimeSeriesEntry.fromJson(
e as Map<String, dynamic>,
),
)
.toList(),
breakdown: (json['breakdown'] as List?)
?.map<UniqueUsersBreakdownEntry>(
(e) =>
UniqueUsersBreakdownEntry.fromJson(e as Map<String, dynamic>),
)
.toList(),
),
);
}
/// Convenience to create a nullable type from a nullable json object.
/// Useful when parsing optional fields.
static GetUniqueUsersResponse? maybeFromJson(Map<String, dynamic>? json) {
if (json == null) {
return null;
}
return GetUniqueUsersResponse.fromJson(json);
}
/// Distinct active devices over the whole window (an HLL count).
final int uniqueUsers;
/// The time-series bucket resolution (`hour`, `day`, `week`, or
/// `month`), or null when no time series was requested.
final String? granularity;
/// The window the unique-users response covers.
final UniqueUsersRange range;
/// Server's UTC timestamp at the moment the response was constructed.
/// Not a freshness indicator for the underlying data, which is
/// refreshed by an hourly scheduled query and may lag by up to ~1 hour.
final DateTime asOf;
/// The top-level time series, present only when a `granularity` was
/// requested; otherwise null.
final List<UniqueUsersTimeSeriesEntry>? timeSeries;
/// Per-group unique users, present only when a `group_by` was
/// requested; otherwise null.
final List<UniqueUsersBreakdownEntry>? breakdown;
/// Converts a [GetUniqueUsersResponse] to a `Map<String, dynamic>`.
Map<String, dynamic> toJson() {
return {
'unique_users': uniqueUsers,
'granularity': granularity,
'range': range.toJson(),
'as_of': asOf.toIso8601String(),
'time_series': timeSeries?.map((e) => e.toJson()).toList(),
'breakdown': breakdown?.map((e) => e.toJson()).toList(),
};
}
@override
int get hashCode => Object.hashAll([
uniqueUsers,
granularity,
range,
asOf,
listHash(timeSeries),
listHash(breakdown),
]);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is GetUniqueUsersResponse &&
uniqueUsers == other.uniqueUsers &&
granularity == other.granularity &&
range == other.range &&
asOf == other.asOf &&
listsEqual(timeSeries, other.timeSeries) &&
listsEqual(breakdown, other.breakdown);
}
}
@@ -0,0 +1,38 @@
enum GetUniqueUsersParameter2 {
hour._('hour'),
day._('day'),
week._('week'),
month._('month');
const GetUniqueUsersParameter2._(this.value);
/// Creates a GetUniqueUsersParameter2 from a json value.
factory GetUniqueUsersParameter2.fromJson(String json) {
return GetUniqueUsersParameter2.values.firstWhere(
(value) => value.value == json,
orElse: () => throw FormatException(
'Unknown GetUniqueUsersParameter2 value: $json',
),
);
}
/// Convenience to create a nullable type from a nullable json value.
/// Useful when parsing optional fields.
static GetUniqueUsersParameter2? maybeFromJson(String? json) {
if (json == null) {
return null;
}
return GetUniqueUsersParameter2.fromJson(json);
}
/// The value of the enum. This is the exact value
/// from the OpenAPI spec and will be used for network transport.
final String value;
/// Converts the enum to its json value.
String toJson() => value;
/// Returns the string form of the enum.
@override
String toString() => value;
}
@@ -0,0 +1,35 @@
enum GetUniqueUsersParameter3 {
platform._('platform');
const GetUniqueUsersParameter3._(this.value);
/// Creates a GetUniqueUsersParameter3 from a json value.
factory GetUniqueUsersParameter3.fromJson(String json) {
return GetUniqueUsersParameter3.values.firstWhere(
(value) => value.value == json,
orElse: () => throw FormatException(
'Unknown GetUniqueUsersParameter3 value: $json',
),
);
}
/// Convenience to create a nullable type from a nullable json value.
/// Useful when parsing optional fields.
static GetUniqueUsersParameter3? maybeFromJson(String? json) {
if (json == null) {
return null;
}
return GetUniqueUsersParameter3.fromJson(json);
}
/// The value of the enum. This is the exact value
/// from the OpenAPI spec and will be used for network transport.
final String value;
/// Converts the enum to its json value.
String toJson() => value;
/// Returns the string form of the enum.
@override
String toString() => value;
}
@@ -0,0 +1,89 @@
import 'package:meta/meta.dart';
import 'package:shorebird_code_push_protocol/model_helpers.dart';
import 'package:shorebird_code_push_protocol/src/models/unique_users_time_series_entry.dart';
/// {@template unique_users_breakdown_entry}
/// Unique users for one value of the `group_by` dimension (e.g. one
/// platform), optionally with its own time series.
/// {@endtemplate}
@immutable
class UniqueUsersBreakdownEntry {
/// {@macro unique_users_breakdown_entry}
const UniqueUsersBreakdownEntry({
required this.groupBy,
required this.groupValue,
required this.uniqueUsers,
this.timeSeries,
});
/// Converts a `Map<String, dynamic>` to a [UniqueUsersBreakdownEntry].
factory UniqueUsersBreakdownEntry.fromJson(Map<String, dynamic> json) {
return parseFromJson(
'UniqueUsersBreakdownEntry',
json,
() => UniqueUsersBreakdownEntry(
groupBy: json['group_by'] as String,
groupValue: json['group_value'] as String,
uniqueUsers: json['unique_users'] as int,
timeSeries: (json['time_series'] as List?)
?.map<UniqueUsersTimeSeriesEntry>(
(e) => UniqueUsersTimeSeriesEntry.fromJson(
e as Map<String, dynamic>,
),
)
.toList(),
),
);
}
/// Convenience to create a nullable type from a nullable json object.
/// Useful when parsing optional fields.
static UniqueUsersBreakdownEntry? maybeFromJson(Map<String, dynamic>? json) {
if (json == null) {
return null;
}
return UniqueUsersBreakdownEntry.fromJson(json);
}
/// The dimension this entry breaks down by (e.g. "platform").
final String groupBy;
/// The value within `group_by` (e.g. "android").
final String groupValue;
/// Distinct active devices for this group over the window (an HLL
/// count).
final int uniqueUsers;
/// Per-bucket series for this group, present only when a `granularity`
/// was requested; otherwise null.
final List<UniqueUsersTimeSeriesEntry>? timeSeries;
/// Converts a [UniqueUsersBreakdownEntry] to a `Map<String, dynamic>`.
Map<String, dynamic> toJson() {
return {
'group_by': groupBy,
'group_value': groupValue,
'unique_users': uniqueUsers,
'time_series': timeSeries?.map((e) => e.toJson()).toList(),
};
}
@override
int get hashCode => Object.hashAll([
groupBy,
groupValue,
uniqueUsers,
listHash(timeSeries),
]);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is UniqueUsersBreakdownEntry &&
groupBy == other.groupBy &&
groupValue == other.groupValue &&
uniqueUsers == other.uniqueUsers &&
listsEqual(timeSeries, other.timeSeries);
}
}
@@ -0,0 +1,63 @@
import 'package:meta/meta.dart';
import 'package:shorebird_code_push_protocol/model_helpers.dart';
/// {@template unique_users_range}
/// The window the unique-users response covers.
/// {@endtemplate}
@immutable
class UniqueUsersRange {
/// {@macro unique_users_range}
const UniqueUsersRange({
required this.start,
required this.end,
});
/// Converts a `Map<String, dynamic>` to a [UniqueUsersRange].
factory UniqueUsersRange.fromJson(Map<String, dynamic> json) {
return parseFromJson(
'UniqueUsersRange',
json,
() => UniqueUsersRange(
start: DateTime.parse(json['start'] as String),
end: DateTime.parse(json['end'] as String),
),
);
}
/// Convenience to create a nullable type from a nullable json object.
/// Useful when parsing optional fields.
static UniqueUsersRange? maybeFromJson(Map<String, dynamic>? json) {
if (json == null) {
return null;
}
return UniqueUsersRange.fromJson(json);
}
/// Window start (UTC, inclusive).
final DateTime start;
/// Window end (UTC, exclusive).
final DateTime end;
/// Converts a [UniqueUsersRange] to a `Map<String, dynamic>`.
Map<String, dynamic> toJson() {
return {
'start': start.toIso8601String(),
'end': end.toIso8601String(),
};
}
@override
int get hashCode => Object.hashAll([
start,
end,
]);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is UniqueUsersRange &&
start == other.start &&
end == other.end;
}
}
@@ -0,0 +1,64 @@
import 'package:meta/meta.dart';
import 'package:shorebird_code_push_protocol/model_helpers.dart';
/// {@template unique_users_time_series_entry}
/// One bucket of a unique-users time series: the HLL count of distinct
/// active devices in the bucket starting at `period`.
/// {@endtemplate}
@immutable
class UniqueUsersTimeSeriesEntry {
/// {@macro unique_users_time_series_entry}
const UniqueUsersTimeSeriesEntry({
required this.period,
required this.uniqueUsers,
});
/// Converts a `Map<String, dynamic>` to a [UniqueUsersTimeSeriesEntry].
factory UniqueUsersTimeSeriesEntry.fromJson(Map<String, dynamic> json) {
return parseFromJson(
'UniqueUsersTimeSeriesEntry',
json,
() => UniqueUsersTimeSeriesEntry(
period: DateTime.parse(json['period'] as String),
uniqueUsers: json['unique_users'] as int,
),
);
}
/// Convenience to create a nullable type from a nullable json object.
/// Useful when parsing optional fields.
static UniqueUsersTimeSeriesEntry? maybeFromJson(Map<String, dynamic>? json) {
if (json == null) {
return null;
}
return UniqueUsersTimeSeriesEntry.fromJson(json);
}
/// The bucket start (UTC).
final DateTime period;
/// Distinct active devices in this bucket (an HLL count).
final int uniqueUsers;
/// Converts a [UniqueUsersTimeSeriesEntry] to a `Map<String, dynamic>`.
Map<String, dynamic> toJson() {
return {
'period': period.toIso8601String(),
'unique_users': uniqueUsers,
};
}
@override
int get hashCode => Object.hashAll([
period,
uniqueUsers,
]);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is UniqueUsersTimeSeriesEntry &&
period == other.period &&
uniqueUsers == other.uniqueUsers;
}
}
@@ -0,0 +1,33 @@
// GENERATED — do not hand-edit.
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group('GetUniqueUsersResponse', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = GetUniqueUsersResponse(
uniqueUsers: 0,
granularity: 'example',
range: UniqueUsersRange(
start: DateTime.utc(2024),
end: DateTime.utc(2024),
),
asOf: DateTime.utc(2024),
);
final parsed = GetUniqueUsersResponse.maybeFromJson(instance.toJson());
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
test('maybeFromJson returns null on null input', () {
expect(GetUniqueUsersResponse.maybeFromJson(null), isNull);
});
test('maybeFromJson throws FormatException on invalid input', () {
expect(
() => GetUniqueUsersResponse.maybeFromJson(<String, dynamic>{}),
throwsFormatException,
);
});
});
}
@@ -0,0 +1,40 @@
// GENERATED — do not hand-edit.
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group('GetUniqueUsersParameter2', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = GetUniqueUsersParameter2.values.first;
final parsed = GetUniqueUsersParameter2.maybeFromJson(instance.toJson());
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
test('maybeFromJson returns null on null input', () {
expect(GetUniqueUsersParameter2.maybeFromJson(null), isNull);
});
test('maybeFromJson throws FormatException on invalid input', () {
expect(
() => GetUniqueUsersParameter2.maybeFromJson('__invalid_enum_value__'),
throwsFormatException,
);
});
test('toString matches toJson for every value', () {
for (final value in GetUniqueUsersParameter2.values) {
expect(value.toString(), equals(value.toJson()));
}
});
test('fromJson round-trips every value', () {
for (final value in GetUniqueUsersParameter2.values) {
expect(
GetUniqueUsersParameter2.fromJson(value.toJson()),
equals(value),
);
}
});
});
}
@@ -0,0 +1,40 @@
// GENERATED — do not hand-edit.
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group('GetUniqueUsersParameter3', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = GetUniqueUsersParameter3.values.first;
final parsed = GetUniqueUsersParameter3.maybeFromJson(instance.toJson());
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
test('maybeFromJson returns null on null input', () {
expect(GetUniqueUsersParameter3.maybeFromJson(null), isNull);
});
test('maybeFromJson throws FormatException on invalid input', () {
expect(
() => GetUniqueUsersParameter3.maybeFromJson('__invalid_enum_value__'),
throwsFormatException,
);
});
test('toString matches toJson for every value', () {
for (final value in GetUniqueUsersParameter3.values) {
expect(value.toString(), equals(value.toJson()));
}
});
test('fromJson round-trips every value', () {
for (final value in GetUniqueUsersParameter3.values) {
expect(
GetUniqueUsersParameter3.fromJson(value.toJson()),
equals(value),
);
}
});
});
}
@@ -0,0 +1,31 @@
// GENERATED — do not hand-edit.
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group('UniqueUsersBreakdownEntry', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = UniqueUsersBreakdownEntry(
groupBy: 'example',
groupValue: 'example',
uniqueUsers: 0,
);
final parsed = UniqueUsersBreakdownEntry.maybeFromJson(
instance.toJson(),
);
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
test('maybeFromJson returns null on null input', () {
expect(UniqueUsersBreakdownEntry.maybeFromJson(null), isNull);
});
test('maybeFromJson throws FormatException on invalid input', () {
expect(
() => UniqueUsersBreakdownEntry.maybeFromJson(<String, dynamic>{}),
throwsFormatException,
);
});
});
}
@@ -0,0 +1,28 @@
// GENERATED — do not hand-edit.
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group('UniqueUsersRange', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = UniqueUsersRange(
start: DateTime.utc(2024),
end: DateTime.utc(2024),
);
final parsed = UniqueUsersRange.maybeFromJson(instance.toJson());
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
test('maybeFromJson returns null on null input', () {
expect(UniqueUsersRange.maybeFromJson(null), isNull);
});
test('maybeFromJson throws FormatException on invalid input', () {
expect(
() => UniqueUsersRange.maybeFromJson(<String, dynamic>{}),
throwsFormatException,
);
});
});
}
@@ -0,0 +1,30 @@
// GENERATED — do not hand-edit.
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
void main() {
group('UniqueUsersTimeSeriesEntry', () {
test('round-trips via maybeFromJson/toJson', () {
final instance = UniqueUsersTimeSeriesEntry(
period: DateTime.utc(2024),
uniqueUsers: 0,
);
final parsed = UniqueUsersTimeSeriesEntry.maybeFromJson(
instance.toJson(),
);
expect(parsed, equals(instance));
expect(parsed.hashCode, equals(instance.hashCode));
});
test('maybeFromJson returns null on null input', () {
expect(UniqueUsersTimeSeriesEntry.maybeFromJson(null), isNull);
});
test('maybeFromJson throws FormatException on invalid input', () {
expect(
() => UniqueUsersTimeSeriesEntry.maybeFromJson(<String, dynamic>{}),
throwsFormatException,
);
});
});
}