@hot-updater/plugin-insights 1.0.0-rc.28 → 1.0.0-rc.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,4 +31,5 @@ const readErrorDetails = (error) => {
31
31
  };
32
32
  };
33
33
  //#endregion
34
+ exports.boundedText = boundedText;
34
35
  exports.readErrorDetails = readErrorDetails;
@@ -31,4 +31,4 @@ const readErrorDetails = (error) => {
31
31
  };
32
32
  };
33
33
  //#endregion
34
- export { readErrorDetails };
34
+ export { boundedText, readErrorDetails };
@@ -46,8 +46,13 @@ const insights = (options = {}) => {
46
46
  const now = context.now();
47
47
  const installId = context.installId;
48
48
  const userId = state.readUserId();
49
+ const response = state.readHttpResponse(movement.channel, appVersion);
49
50
  const body = {
50
51
  ...movement,
52
+ ...response ? { metadata: {
53
+ ...movement.metadata,
54
+ httpResponse: response
55
+ } } : {},
51
56
  eventId: eventId?.(now, installId) ?? require_eventId.createUUIDv7(),
52
57
  installId,
53
58
  ...userId === null ? {} : { userId },
@@ -176,6 +181,17 @@ const insights = (options = {}) => {
176
181
  failureKey
177
182
  });
178
183
  };
184
+ const onHttpResponse = (response) => {
185
+ if (context === null || state === null || context.appVersion === null) return;
186
+ const body = response.body === null ? null : require_errorDetails.boundedText(response.body, 4096);
187
+ state.recordHttpResponse(context.getChannel(), context.appVersion, {
188
+ ...response,
189
+ path: require_errorDetails.boundedText(response.path, 1024),
190
+ body,
191
+ bodyTruncated: response.bodyTruncated || body !== response.body,
192
+ receivedAtMs: context.now()
193
+ });
194
+ };
179
195
  const admit = (body) => {
180
196
  const event = pending.get(body);
181
197
  if (event === void 0 || state === null || context === null) return false;
@@ -220,7 +236,8 @@ const insights = (options = {}) => {
220
236
  onAppReady,
221
237
  onUpdateCheck,
222
238
  onBundleDownloaded,
223
- onUpdateError
239
+ onUpdateError,
240
+ onHttpResponse
224
241
  };
225
242
  },
226
243
  setUser(user) {
@@ -1,4 +1,4 @@
1
- import { readErrorDetails } from "./errorDetails.mjs";
1
+ import { boundedText, readErrorDetails } from "./errorDetails.mjs";
2
2
  import { createKeyedUUIDv7, createUUIDv7 } from "./eventId.mjs";
3
3
  import { createInsightsEventSender } from "./sender.mjs";
4
4
  import { createInsightsState, utcDay, utcDayStart } from "./state.mjs";
@@ -45,8 +45,13 @@ const insights = (options = {}) => {
45
45
  const now = context.now();
46
46
  const installId = context.installId;
47
47
  const userId = state.readUserId();
48
+ const response = state.readHttpResponse(movement.channel, appVersion);
48
49
  const body = {
49
50
  ...movement,
51
+ ...response ? { metadata: {
52
+ ...movement.metadata,
53
+ httpResponse: response
54
+ } } : {},
50
55
  eventId: eventId?.(now, installId) ?? createUUIDv7(),
51
56
  installId,
52
57
  ...userId === null ? {} : { userId },
@@ -175,6 +180,17 @@ const insights = (options = {}) => {
175
180
  failureKey
176
181
  });
177
182
  };
183
+ const onHttpResponse = (response) => {
184
+ if (context === null || state === null || context.appVersion === null) return;
185
+ const body = response.body === null ? null : boundedText(response.body, 4096);
186
+ state.recordHttpResponse(context.getChannel(), context.appVersion, {
187
+ ...response,
188
+ path: boundedText(response.path, 1024),
189
+ body,
190
+ bodyTruncated: response.bodyTruncated || body !== response.body,
191
+ receivedAtMs: context.now()
192
+ });
193
+ };
178
194
  const admit = (body) => {
179
195
  const event = pending.get(body);
180
196
  if (event === void 0 || state === null || context === null) return false;
@@ -219,7 +235,8 @@ const insights = (options = {}) => {
219
235
  onAppReady,
220
236
  onUpdateCheck,
221
237
  onBundleDownloaded,
222
- onUpdateError
238
+ onUpdateError,
239
+ onHttpResponse
223
240
  };
224
241
  },
225
242
  setUser(user) {
@@ -102,6 +102,24 @@ const createInsightsState = (storage) => {
102
102
  keys: [...keys, key].slice(-32)
103
103
  }));
104
104
  },
105
+ readHttpResponse(channel, appVersion) {
106
+ try {
107
+ const saved = JSON.parse(read("httpResponse") ?? "null");
108
+ if (!isRecord(saved) || saved.channel !== channel || saved.appVersion !== appVersion || !isRecord(saved.response)) return void 0;
109
+ const response = saved.response;
110
+ if (response.resource !== "catalog" && response.resource !== "artifact" || typeof response.path !== "string" || response.path.length > 1024 || typeof response.status !== "number" || !Number.isInteger(response.status) || response.status < 100 || response.status > 599 || !isNullableString(response.body) || JSON.stringify(response.body).length > 4096 || typeof response.bodyTruncated !== "boolean" || typeof response.receivedAtMs !== "number" || !Number.isSafeInteger(response.receivedAtMs) || response.receivedAtMs < 0) return void 0;
111
+ return response;
112
+ } catch {
113
+ return;
114
+ }
115
+ },
116
+ recordHttpResponse(channel, appVersion, response) {
117
+ write("httpResponse", JSON.stringify({
118
+ channel,
119
+ appVersion,
120
+ response
121
+ }));
122
+ },
105
123
  readUserId() {
106
124
  try {
107
125
  const user = JSON.parse(read("user") ?? "null");
@@ -102,6 +102,24 @@ const createInsightsState = (storage) => {
102
102
  keys: [...keys, key].slice(-32)
103
103
  }));
104
104
  },
105
+ readHttpResponse(channel, appVersion) {
106
+ try {
107
+ const saved = JSON.parse(read("httpResponse") ?? "null");
108
+ if (!isRecord(saved) || saved.channel !== channel || saved.appVersion !== appVersion || !isRecord(saved.response)) return void 0;
109
+ const response = saved.response;
110
+ if (response.resource !== "catalog" && response.resource !== "artifact" || typeof response.path !== "string" || response.path.length > 1024 || typeof response.status !== "number" || !Number.isInteger(response.status) || response.status < 100 || response.status > 599 || !isNullableString(response.body) || JSON.stringify(response.body).length > 4096 || typeof response.bodyTruncated !== "boolean" || typeof response.receivedAtMs !== "number" || !Number.isSafeInteger(response.receivedAtMs) || response.receivedAtMs < 0) return void 0;
111
+ return response;
112
+ } catch {
113
+ return;
114
+ }
115
+ },
116
+ recordHttpResponse(channel, appVersion, response) {
117
+ write("httpResponse", JSON.stringify({
118
+ channel,
119
+ appVersion,
120
+ response
121
+ }));
122
+ },
105
123
  readUserId() {
106
124
  try {
107
125
  const user = JSON.parse(read("user") ?? "null");
@@ -154,19 +154,40 @@ const createValidatedInsightsModel = (model) => ({
154
154
  if (!require_eventRow.isRecord(input) || !hasOnlyKeys(input, [
155
155
  "releases",
156
156
  "scope",
157
- "timeRange"
157
+ "timeRange",
158
+ "intervalMs"
158
159
  ])) invalidQuery();
159
160
  const releases = input.releases;
160
161
  const scope = input.scope;
161
162
  const timeRange = input.timeRange;
162
- const releaseQuery = Array.isArray(releases) && releases.length > 0 && releases.length <= 100 && releases.every(isReleaseReference) && scope === void 0 && (timeRange === void 0 || isTimeRange(timeRange));
163
- const scopeQuery = releases === void 0 && require_eventRow.isRecord(scope) && hasOnlyKeys(scope, ["platform", "channel"]) && hasScope(scope) && isTimeRange(timeRange);
163
+ const intervalMs = input.intervalMs;
164
+ const releaseQuery = Array.isArray(releases) && releases.length > 0 && releases.length <= 100 && releases.every(isReleaseReference) && scope === void 0 && (timeRange === void 0 ? intervalMs === void 0 : isTimeRange(timeRange) && (intervalMs === void 0 || isTimestamp(intervalMs) && intervalMs > 0 && intervalMs % 36e5 === 0 && (timeRange.end - timeRange.start) / intervalMs <= 8784));
165
+ const scopeQuery = releases === void 0 && intervalMs === void 0 && require_eventRow.isRecord(scope) && hasOnlyKeys(scope, ["platform", "channel"]) && hasScope(scope) && isTimeRange(timeRange);
164
166
  if (!releaseQuery && !scopeQuery) invalidQuery();
165
167
  const result = await model.getReleaseActivity(input);
166
168
  validateAggregateResult(result);
167
169
  if (!Array.isArray(result.data)) invalidResult();
168
170
  return result;
169
171
  },
172
+ async getDistributionHistory(input) {
173
+ if (!require_eventRow.isRecord(input) || !hasOnlyKeys(input, [
174
+ "channel",
175
+ "platform",
176
+ "timeRange"
177
+ ]) || !hasScope(input) || !isTimeRange(input.timeRange) || input.timeRange.start % 864e5 !== 0 || input.timeRange.end % 864e5 !== 0 || input.timeRange.end - input.timeRange.start > 26784e5) invalidQuery();
178
+ const result = await model.getDistributionHistory(input);
179
+ validateAggregateResult(result);
180
+ if (!Array.isArray(result.points)) invalidResult();
181
+ for (const point of result.points) {
182
+ if (!isTimestamp(point.startMs) || !Array.isArray(point.bundles)) invalidResult();
183
+ for (const bundle of point.bundles) if (!isText(bundle.appVersion) || bundle.releaseId !== null && !isText(bundle.releaseId) || ![
184
+ "release",
185
+ "builtin",
186
+ "unknown"
187
+ ].includes(bundle.bundleKind) || !isTimestamp(bundle.installations)) invalidResult();
188
+ }
189
+ return result;
190
+ },
170
191
  async getAppUsage(input) {
171
192
  if (!require_eventRow.isRecord(input) || !hasOnlyKeys(input, [
172
193
  "channel",
@@ -154,19 +154,40 @@ const createValidatedInsightsModel = (model) => ({
154
154
  if (!isRecord(input) || !hasOnlyKeys(input, [
155
155
  "releases",
156
156
  "scope",
157
- "timeRange"
157
+ "timeRange",
158
+ "intervalMs"
158
159
  ])) invalidQuery();
159
160
  const releases = input.releases;
160
161
  const scope = input.scope;
161
162
  const timeRange = input.timeRange;
162
- const releaseQuery = Array.isArray(releases) && releases.length > 0 && releases.length <= 100 && releases.every(isReleaseReference) && scope === void 0 && (timeRange === void 0 || isTimeRange(timeRange));
163
- const scopeQuery = releases === void 0 && isRecord(scope) && hasOnlyKeys(scope, ["platform", "channel"]) && hasScope(scope) && isTimeRange(timeRange);
163
+ const intervalMs = input.intervalMs;
164
+ const releaseQuery = Array.isArray(releases) && releases.length > 0 && releases.length <= 100 && releases.every(isReleaseReference) && scope === void 0 && (timeRange === void 0 ? intervalMs === void 0 : isTimeRange(timeRange) && (intervalMs === void 0 || isTimestamp(intervalMs) && intervalMs > 0 && intervalMs % 36e5 === 0 && (timeRange.end - timeRange.start) / intervalMs <= 8784));
165
+ const scopeQuery = releases === void 0 && intervalMs === void 0 && isRecord(scope) && hasOnlyKeys(scope, ["platform", "channel"]) && hasScope(scope) && isTimeRange(timeRange);
164
166
  if (!releaseQuery && !scopeQuery) invalidQuery();
165
167
  const result = await model.getReleaseActivity(input);
166
168
  validateAggregateResult(result);
167
169
  if (!Array.isArray(result.data)) invalidResult();
168
170
  return result;
169
171
  },
172
+ async getDistributionHistory(input) {
173
+ if (!isRecord(input) || !hasOnlyKeys(input, [
174
+ "channel",
175
+ "platform",
176
+ "timeRange"
177
+ ]) || !hasScope(input) || !isTimeRange(input.timeRange) || input.timeRange.start % 864e5 !== 0 || input.timeRange.end % 864e5 !== 0 || input.timeRange.end - input.timeRange.start > 26784e5) invalidQuery();
178
+ const result = await model.getDistributionHistory(input);
179
+ validateAggregateResult(result);
180
+ if (!Array.isArray(result.points)) invalidResult();
181
+ for (const point of result.points) {
182
+ if (!isTimestamp(point.startMs) || !Array.isArray(point.bundles)) invalidResult();
183
+ for (const bundle of point.bundles) if (!isText(bundle.appVersion) || bundle.releaseId !== null && !isText(bundle.releaseId) || ![
184
+ "release",
185
+ "builtin",
186
+ "unknown"
187
+ ].includes(bundle.bundleKind) || !isTimestamp(bundle.installations)) invalidResult();
188
+ }
189
+ return result;
190
+ },
170
191
  async getAppUsage(input) {
171
192
  if (!isRecord(input) || !hasOnlyKeys(input, [
172
193
  "channel",
@@ -1,5 +1,6 @@
1
1
  import { BundleEventFailure, BundleEventFailureReason, BundleEventFailureStage, DatabaseBundleEventMetadata } from "./eventRow.cjs";
2
2
  import { InsightsScope } from "./modelTypes.cjs";
3
+ import { UpdateHttpResponse } from "@hot-updater/protocol";
3
4
  //#region src/server/domain.d.ts
4
5
  /**
5
6
  * Where an update failed and why, as a report carries it. Unknown values of
@@ -33,6 +34,9 @@ type CreateBundleEventRequestBase = {
33
34
  * server creates the ID, and a retry counts as another report.
34
35
  */
35
36
  readonly eventId?: string;
37
+ readonly metadata?: {
38
+ readonly httpResponse?: InsightsHttpResponse;
39
+ };
36
40
  readonly installId: string;
37
41
  readonly toBundleId: string;
38
42
  readonly userId?: string;
@@ -94,6 +98,7 @@ type EventHistoryRow = {
94
98
  readonly channel: string;
95
99
  readonly cohort: string;
96
100
  readonly receivedAtMs: number;
101
+ readonly httpResponse?: InsightsHttpResponse;
97
102
  /** `UPDATE_FAILED`: where the update failed and why. */
98
103
  readonly failure?: BundleEventFailureInput;
99
104
  /** `UPDATE_DOWNLOADED`: how the bundle arrived, when the client said. */
@@ -107,7 +112,11 @@ type InstallationHistoryRow = EventHistoryRow & {
107
112
  readonly type: "UPDATE_DOWNLOADED" | "UPDATE_APPLIED" | "RECOVERED" | "UPDATE_FAILED";
108
113
  readonly fromBundleId: string;
109
114
  };
115
+ type InsightsHttpResponse = UpdateHttpResponse & {
116
+ readonly receivedAtMs: number;
117
+ };
110
118
  type InstallationRow = {
119
+ readonly httpResponse?: InsightsHttpResponse;
111
120
  readonly installId: string;
112
121
  readonly userId: string | null;
113
122
  readonly lastKnownBundleId: string;
@@ -151,4 +160,4 @@ type ReportingOverview = InsightsScope & {
151
160
  };
152
161
  };
153
162
  //#endregion
154
- export { ActiveInstallationWindow, BundleEventFailureInput, CreateBundleEventRequest, CreateBundleEventRequestBase, CursorPage, EventCursorPage, EventHistoryRow, InsightsBundleSelection, InsightsCountMeasurement, InstallationHistoryRow, InstallationRow, ReportingOverview };
163
+ export { ActiveInstallationWindow, BundleEventFailureInput, CreateBundleEventRequest, CreateBundleEventRequestBase, CursorPage, EventCursorPage, EventHistoryRow, InsightsBundleSelection, InsightsCountMeasurement, InsightsHttpResponse, InstallationHistoryRow, InstallationRow, ReportingOverview };
@@ -1,5 +1,6 @@
1
1
  import { BundleEventFailure, BundleEventFailureReason, BundleEventFailureStage, DatabaseBundleEventMetadata } from "./eventRow.mjs";
2
2
  import { InsightsScope } from "./modelTypes.mjs";
3
+ import { UpdateHttpResponse } from "@hot-updater/protocol";
3
4
  //#region src/server/domain.d.ts
4
5
  /**
5
6
  * Where an update failed and why, as a report carries it. Unknown values of
@@ -33,6 +34,9 @@ type CreateBundleEventRequestBase = {
33
34
  * server creates the ID, and a retry counts as another report.
34
35
  */
35
36
  readonly eventId?: string;
37
+ readonly metadata?: {
38
+ readonly httpResponse?: InsightsHttpResponse;
39
+ };
36
40
  readonly installId: string;
37
41
  readonly toBundleId: string;
38
42
  readonly userId?: string;
@@ -94,6 +98,7 @@ type EventHistoryRow = {
94
98
  readonly channel: string;
95
99
  readonly cohort: string;
96
100
  readonly receivedAtMs: number;
101
+ readonly httpResponse?: InsightsHttpResponse;
97
102
  /** `UPDATE_FAILED`: where the update failed and why. */
98
103
  readonly failure?: BundleEventFailureInput;
99
104
  /** `UPDATE_DOWNLOADED`: how the bundle arrived, when the client said. */
@@ -107,7 +112,11 @@ type InstallationHistoryRow = EventHistoryRow & {
107
112
  readonly type: "UPDATE_DOWNLOADED" | "UPDATE_APPLIED" | "RECOVERED" | "UPDATE_FAILED";
108
113
  readonly fromBundleId: string;
109
114
  };
115
+ type InsightsHttpResponse = UpdateHttpResponse & {
116
+ readonly receivedAtMs: number;
117
+ };
110
118
  type InstallationRow = {
119
+ readonly httpResponse?: InsightsHttpResponse;
111
120
  readonly installId: string;
112
121
  readonly userId: string | null;
113
122
  readonly lastKnownBundleId: string;
@@ -151,4 +160,4 @@ type ReportingOverview = InsightsScope & {
151
160
  };
152
161
  };
153
162
  //#endregion
154
- export { ActiveInstallationWindow, BundleEventFailureInput, CreateBundleEventRequest, CreateBundleEventRequestBase, CursorPage, EventCursorPage, EventHistoryRow, InsightsBundleSelection, InsightsCountMeasurement, InstallationHistoryRow, InstallationRow, ReportingOverview };
163
+ export { ActiveInstallationWindow, BundleEventFailureInput, CreateBundleEventRequest, CreateBundleEventRequestBase, CursorPage, EventCursorPage, EventHistoryRow, InsightsBundleSelection, InsightsCountMeasurement, InsightsHttpResponse, InstallationHistoryRow, InstallationRow, ReportingOverview };
@@ -98,6 +98,17 @@ function readFailure(value) {
98
98
  ...previousProcessExit === void 0 ? {} : { previousProcessExit }
99
99
  };
100
100
  }
101
+ function readHttpResponse(value) {
102
+ if (!isRecord(value) || value.resource !== "catalog" && value.resource !== "artifact" || typeof value.path !== "string" || value.path.length > 1024 || !Number.isSafeInteger(value.status) || value.status < 100 || value.status > 599 || value.body !== null && (typeof value.body !== "string" || new TextEncoder().encode(JSON.stringify(value.body)).byteLength > 4096) || typeof value.bodyTruncated !== "boolean" || !Number.isSafeInteger(value.receivedAtMs) || value.receivedAtMs < 0) throw new require_errors.InsightsBadRequestError("Invalid HTTP response details");
103
+ return {
104
+ resource: value.resource,
105
+ path: value.path,
106
+ status: value.status,
107
+ body: value.body,
108
+ bodyTruncated: value.bodyTruncated,
109
+ receivedAtMs: value.receivedAtMs
110
+ };
111
+ }
101
112
  async function readBoundedText(request) {
102
113
  const contentLength = request.headers.get("content-length");
103
114
  const declaredByteLength = Number(contentLength);
@@ -133,7 +144,10 @@ function requireEvent(payload) {
133
144
  const platform = requireStringField(payload, "platform");
134
145
  if (platform !== "ios" && platform !== "android") throw new require_errors.InsightsBadRequestError("Invalid event field: platform");
135
146
  const eventId = readEventId(payload);
147
+ const metadata = readMetadata(payload);
148
+ const httpMetadata = metadata.httpResponse === void 0 ? {} : { httpResponse: readHttpResponse(metadata.httpResponse) };
136
149
  const base = {
150
+ ...metadata.httpResponse === void 0 ? {} : { metadata: httpMetadata },
137
151
  ...eventId === void 0 ? {} : { eventId },
138
152
  installId: requireIdentityField(payload, "installId"),
139
153
  toBundleId: requireStringField(payload, "toBundleId"),
@@ -157,11 +171,11 @@ function requireEvent(payload) {
157
171
  updateStrategy
158
172
  };
159
173
  };
160
- const metadata = readMetadata(payload);
161
174
  switch (type) {
162
175
  case "UPDATE_DOWNLOADED": {
163
176
  const delivery = metadata.delivery;
164
177
  const read = {
178
+ ...httpMetadata,
165
179
  ...delivery == null ? {} : { delivery: oneOf(DELIVERIES, delivery) },
166
180
  ...metadata.patchFallback === true ? { patchFallback: true } : {}
167
181
  };
@@ -180,13 +194,19 @@ function requireEvent(payload) {
180
194
  return {
181
195
  ...movement(),
182
196
  type,
183
- ...previousProcessExit === void 0 ? {} : { metadata: { previousProcessExit } }
197
+ ...previousProcessExit === void 0 ? {} : { metadata: {
198
+ ...httpMetadata,
199
+ previousProcessExit
200
+ } }
184
201
  };
185
202
  }
186
203
  case "UPDATE_FAILED": return {
187
204
  ...movement(),
188
205
  type,
189
- metadata: { failure: readFailure(metadata.failure) }
206
+ metadata: {
207
+ ...httpMetadata,
208
+ failure: readFailure(metadata.failure)
209
+ }
190
210
  };
191
211
  case "UNCHANGED":
192
212
  if (payload.fromBundleId !== null || payload.updateStrategy !== null) throw new require_errors.InsightsBadRequestError("Invalid unchanged event shape");
@@ -216,7 +236,16 @@ function createBundleEventRow(input) {
216
236
  to_release_id: input.toReleaseId,
217
237
  user_id: input.userId ?? null
218
238
  };
239
+ const response = input.metadata?.httpResponse;
219
240
  const metadata = {
241
+ ...response ? { http_response: {
242
+ resource: response.resource,
243
+ path: response.path,
244
+ status: response.status,
245
+ body: response.body,
246
+ body_truncated: response.bodyTruncated,
247
+ received_at_ms: response.receivedAtMs
248
+ } } : {},
220
249
  cohort: input.cohort,
221
250
  fingerprint_hash: input.fingerprintHash,
222
251
  sdk_version: input.sdkVersion ?? null,
@@ -98,6 +98,17 @@ function readFailure(value) {
98
98
  ...previousProcessExit === void 0 ? {} : { previousProcessExit }
99
99
  };
100
100
  }
101
+ function readHttpResponse(value) {
102
+ if (!isRecord(value) || value.resource !== "catalog" && value.resource !== "artifact" || typeof value.path !== "string" || value.path.length > 1024 || !Number.isSafeInteger(value.status) || value.status < 100 || value.status > 599 || value.body !== null && (typeof value.body !== "string" || new TextEncoder().encode(JSON.stringify(value.body)).byteLength > 4096) || typeof value.bodyTruncated !== "boolean" || !Number.isSafeInteger(value.receivedAtMs) || value.receivedAtMs < 0) throw new InsightsBadRequestError("Invalid HTTP response details");
103
+ return {
104
+ resource: value.resource,
105
+ path: value.path,
106
+ status: value.status,
107
+ body: value.body,
108
+ bodyTruncated: value.bodyTruncated,
109
+ receivedAtMs: value.receivedAtMs
110
+ };
111
+ }
101
112
  async function readBoundedText(request) {
102
113
  const contentLength = request.headers.get("content-length");
103
114
  const declaredByteLength = Number(contentLength);
@@ -133,7 +144,10 @@ function requireEvent(payload) {
133
144
  const platform = requireStringField(payload, "platform");
134
145
  if (platform !== "ios" && platform !== "android") throw new InsightsBadRequestError("Invalid event field: platform");
135
146
  const eventId = readEventId(payload);
147
+ const metadata = readMetadata(payload);
148
+ const httpMetadata = metadata.httpResponse === void 0 ? {} : { httpResponse: readHttpResponse(metadata.httpResponse) };
136
149
  const base = {
150
+ ...metadata.httpResponse === void 0 ? {} : { metadata: httpMetadata },
137
151
  ...eventId === void 0 ? {} : { eventId },
138
152
  installId: requireIdentityField(payload, "installId"),
139
153
  toBundleId: requireStringField(payload, "toBundleId"),
@@ -157,11 +171,11 @@ function requireEvent(payload) {
157
171
  updateStrategy
158
172
  };
159
173
  };
160
- const metadata = readMetadata(payload);
161
174
  switch (type) {
162
175
  case "UPDATE_DOWNLOADED": {
163
176
  const delivery = metadata.delivery;
164
177
  const read = {
178
+ ...httpMetadata,
165
179
  ...delivery == null ? {} : { delivery: oneOf(DELIVERIES, delivery) },
166
180
  ...metadata.patchFallback === true ? { patchFallback: true } : {}
167
181
  };
@@ -180,13 +194,19 @@ function requireEvent(payload) {
180
194
  return {
181
195
  ...movement(),
182
196
  type,
183
- ...previousProcessExit === void 0 ? {} : { metadata: { previousProcessExit } }
197
+ ...previousProcessExit === void 0 ? {} : { metadata: {
198
+ ...httpMetadata,
199
+ previousProcessExit
200
+ } }
184
201
  };
185
202
  }
186
203
  case "UPDATE_FAILED": return {
187
204
  ...movement(),
188
205
  type,
189
- metadata: { failure: readFailure(metadata.failure) }
206
+ metadata: {
207
+ ...httpMetadata,
208
+ failure: readFailure(metadata.failure)
209
+ }
190
210
  };
191
211
  case "UNCHANGED":
192
212
  if (payload.fromBundleId !== null || payload.updateStrategy !== null) throw new InsightsBadRequestError("Invalid unchanged event shape");
@@ -216,7 +236,16 @@ function createBundleEventRow(input) {
216
236
  to_release_id: input.toReleaseId,
217
237
  user_id: input.userId ?? null
218
238
  };
239
+ const response = input.metadata?.httpResponse;
219
240
  const metadata = {
241
+ ...response ? { http_response: {
242
+ resource: response.resource,
243
+ path: response.path,
244
+ status: response.status,
245
+ body: response.body,
246
+ body_truncated: response.bodyTruncated,
247
+ received_at_ms: response.receivedAtMs
248
+ } } : {},
220
249
  cohort: input.cohort,
221
250
  fingerprint_hash: input.fingerprintHash,
222
251
  sdk_version: input.sdkVersion ?? null,
@@ -2,9 +2,10 @@ let _hot_updater_plugin_core = require("@hot-updater/plugin-core");
2
2
  //#region src/server/eventRow.ts
3
3
  const isOptional = (value, key, valid) => !Object.hasOwn(value, key) || valid(value[key]);
4
4
  const isString = (value) => typeof value === "string";
5
+ const isDatabaseHttpResponse = (value) => (0, _hot_updater_plugin_core.isDatabaseJsonObject)(value) && (value.resource === "catalog" || value.resource === "artifact") && isString(value.path) && Number.isSafeInteger(value.status) && (value.body === null || isString(value.body)) && typeof value.body_truncated === "boolean" && Number.isSafeInteger(value.received_at_ms);
5
6
  /** An `UPDATE_FAILED` report's stored failure: a stage and a reason, and what else the client knew. */
6
7
  const isDatabaseBundleEventFailure = (value) => (0, _hot_updater_plugin_core.isDatabaseJsonObject)(value) && isString(value.stage) && isString(value.reason) && isOptional(value, "resource", isString) && isOptional(value, "http_status", (status) => Number.isSafeInteger(status)) && isOptional(value, "transport", isString) && isOptional(value, "origin_code", isString) && isOptional(value, "error_message", isString) && isOptional(value, "error_stack", isString) && isOptional(value, "previous_process_exit", isString);
7
- const isDatabaseBundleEventMetadata = (value) => (0, _hot_updater_plugin_core.isDatabaseJsonObject)(value) && typeof value.cohort === "string" && (value.update_strategy === null || value.update_strategy === "fingerprint" || value.update_strategy === "appVersion") && (value.fingerprint_hash === null || typeof value.fingerprint_hash === "string") && (value.sdk_version === null || typeof value.sdk_version === "string") && isOptional(value, "failure", isDatabaseBundleEventFailure) && isOptional(value, "delivery", isString) && isOptional(value, "patch_fallback", (flag) => typeof flag === "boolean") && isOptional(value, "previous_process_exit", isString);
8
+ const isDatabaseBundleEventMetadata = (value) => (0, _hot_updater_plugin_core.isDatabaseJsonObject)(value) && typeof value.cohort === "string" && (value.update_strategy === null || value.update_strategy === "fingerprint" || value.update_strategy === "appVersion") && (value.fingerprint_hash === null || typeof value.fingerprint_hash === "string") && (value.sdk_version === null || typeof value.sdk_version === "string") && isOptional(value, "failure", isDatabaseBundleEventFailure) && isOptional(value, "http_response", isDatabaseHttpResponse) && isOptional(value, "delivery", isString) && isOptional(value, "patch_fallback", (flag) => typeof flag === "boolean") && isOptional(value, "previous_process_exit", isString);
8
9
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
9
10
  const isIdentity = (value) => typeof value === "string" && value.length > 0 && value.length <= 255;
10
11
  const isTextOrNull = (value) => value === null || typeof value === "string";
@@ -19,6 +19,14 @@ type BundleEventFailure = DatabaseJsonObject & {
19
19
  /** Android's `ApplicationExitInfo` reason for the process before this one. */
20
20
  readonly previous_process_exit?: string;
21
21
  };
22
+ type DatabaseHttpResponse = DatabaseJsonObject & {
23
+ readonly resource: "catalog" | "artifact";
24
+ readonly path: string;
25
+ readonly status: number;
26
+ readonly body: string | null;
27
+ readonly body_truncated: boolean;
28
+ readonly received_at_ms: number;
29
+ };
22
30
  /** Ancillary report data; queryable identity and lifecycle fields stay on the row. */
23
31
  type DatabaseBundleEventMetadata = DatabaseJsonObject & {
24
32
  readonly cohort: string;
@@ -27,6 +35,7 @@ type DatabaseBundleEventMetadata = DatabaseJsonObject & {
27
35
  readonly sdk_version: string | null;
28
36
  /** `UPDATE_FAILED`: what failed. */
29
37
  readonly failure?: BundleEventFailure;
38
+ readonly http_response?: DatabaseHttpResponse;
30
39
  /** `UPDATE_DOWNLOADED`: how the bundle arrived. */
31
40
  readonly delivery?: "patch" | "manifest" | "archive" | "unknown";
32
41
  /** `UPDATE_DOWNLOADED`: a patch failed and the full files came instead. */
@@ -63,4 +72,4 @@ type BundleEventRow = BundleEventRowBase & ({
63
72
  readonly from_bundle_id: null;
64
73
  });
65
74
  //#endregion
66
- export { BundleEventFailure, BundleEventFailureReason, BundleEventFailureStage, BundleEventRow, BundleEventRowBase, DatabaseBundleEventMetadata };
75
+ export { BundleEventFailure, BundleEventFailureReason, BundleEventFailureStage, BundleEventRow, BundleEventRowBase, DatabaseBundleEventMetadata, DatabaseHttpResponse };
@@ -19,6 +19,14 @@ type BundleEventFailure = DatabaseJsonObject & {
19
19
  /** Android's `ApplicationExitInfo` reason for the process before this one. */
20
20
  readonly previous_process_exit?: string;
21
21
  };
22
+ type DatabaseHttpResponse = DatabaseJsonObject & {
23
+ readonly resource: "catalog" | "artifact";
24
+ readonly path: string;
25
+ readonly status: number;
26
+ readonly body: string | null;
27
+ readonly body_truncated: boolean;
28
+ readonly received_at_ms: number;
29
+ };
22
30
  /** Ancillary report data; queryable identity and lifecycle fields stay on the row. */
23
31
  type DatabaseBundleEventMetadata = DatabaseJsonObject & {
24
32
  readonly cohort: string;
@@ -27,6 +35,7 @@ type DatabaseBundleEventMetadata = DatabaseJsonObject & {
27
35
  readonly sdk_version: string | null;
28
36
  /** `UPDATE_FAILED`: what failed. */
29
37
  readonly failure?: BundleEventFailure;
38
+ readonly http_response?: DatabaseHttpResponse;
30
39
  /** `UPDATE_DOWNLOADED`: how the bundle arrived. */
31
40
  readonly delivery?: "patch" | "manifest" | "archive" | "unknown";
32
41
  /** `UPDATE_DOWNLOADED`: a patch failed and the full files came instead. */
@@ -63,4 +72,4 @@ type BundleEventRow = BundleEventRowBase & ({
63
72
  readonly from_bundle_id: null;
64
73
  });
65
74
  //#endregion
66
- export { BundleEventFailure, BundleEventFailureReason, BundleEventFailureStage, BundleEventRow, BundleEventRowBase, DatabaseBundleEventMetadata };
75
+ export { BundleEventFailure, BundleEventFailureReason, BundleEventFailureStage, BundleEventRow, BundleEventRowBase, DatabaseBundleEventMetadata, DatabaseHttpResponse };
@@ -2,9 +2,10 @@ import { DatabaseAdapterInputError, isDatabaseJsonObject } from "@hot-updater/pl
2
2
  //#region src/server/eventRow.ts
3
3
  const isOptional = (value, key, valid) => !Object.hasOwn(value, key) || valid(value[key]);
4
4
  const isString = (value) => typeof value === "string";
5
+ const isDatabaseHttpResponse = (value) => isDatabaseJsonObject(value) && (value.resource === "catalog" || value.resource === "artifact") && isString(value.path) && Number.isSafeInteger(value.status) && (value.body === null || isString(value.body)) && typeof value.body_truncated === "boolean" && Number.isSafeInteger(value.received_at_ms);
5
6
  /** An `UPDATE_FAILED` report's stored failure: a stage and a reason, and what else the client knew. */
6
7
  const isDatabaseBundleEventFailure = (value) => isDatabaseJsonObject(value) && isString(value.stage) && isString(value.reason) && isOptional(value, "resource", isString) && isOptional(value, "http_status", (status) => Number.isSafeInteger(status)) && isOptional(value, "transport", isString) && isOptional(value, "origin_code", isString) && isOptional(value, "error_message", isString) && isOptional(value, "error_stack", isString) && isOptional(value, "previous_process_exit", isString);
7
- const isDatabaseBundleEventMetadata = (value) => isDatabaseJsonObject(value) && typeof value.cohort === "string" && (value.update_strategy === null || value.update_strategy === "fingerprint" || value.update_strategy === "appVersion") && (value.fingerprint_hash === null || typeof value.fingerprint_hash === "string") && (value.sdk_version === null || typeof value.sdk_version === "string") && isOptional(value, "failure", isDatabaseBundleEventFailure) && isOptional(value, "delivery", isString) && isOptional(value, "patch_fallback", (flag) => typeof flag === "boolean") && isOptional(value, "previous_process_exit", isString);
8
+ const isDatabaseBundleEventMetadata = (value) => isDatabaseJsonObject(value) && typeof value.cohort === "string" && (value.update_strategy === null || value.update_strategy === "fingerprint" || value.update_strategy === "appVersion") && (value.fingerprint_hash === null || typeof value.fingerprint_hash === "string") && (value.sdk_version === null || typeof value.sdk_version === "string") && isOptional(value, "failure", isDatabaseBundleEventFailure) && isOptional(value, "http_response", isDatabaseHttpResponse) && isOptional(value, "delivery", isString) && isOptional(value, "patch_fallback", (flag) => typeof flag === "boolean") && isOptional(value, "previous_process_exit", isString);
8
9
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
9
10
  const isIdentity = (value) => typeof value === "string" && value.length > 0 && value.length <= 255;
10
11
  const isTextOrNull = (value) => value === null || typeof value === "string";
@@ -19,6 +19,7 @@ const createInsightsApi = (db, now, retention) => ({
19
19
  countLatestEvents: (input) => require_reads.countLatestEvents(db, input),
20
20
  countEvents: (input) => require_reads.countEvents(db, input),
21
21
  getReleaseActivity: (input) => require_reads.getReleaseActivity(db, input, now, retention),
22
+ getDistributionHistory: (input) => require_reads.getDistributionHistory(db, input, now, retention),
22
23
  getAppUsage: (input) => require_reads.getAppUsage(db, input, now, retention),
23
24
  /** A release's or a channel's update failures, and their breakdown over a time range. */
24
25
  getUpdateFailures: (input) => require_reads.getUpdateFailures(db, input, now, retention)
@@ -87,7 +88,7 @@ const insights = (options = {}) => {
87
88
  const plugin = (0, _hot_updater_plugin_core.definePlugin)({
88
89
  id: "insights",
89
90
  namespace: false,
90
- schemaVersion: "1.2.0",
91
+ schemaVersion: "1.3.0",
91
92
  schema: require_schema.createInsightsSchema(retention),
92
93
  init: ({ db, now }) => {
93
94
  const api = createInsightsApi(db, now, retention);
@@ -102,6 +103,32 @@ const insights = (options = {}) => {
102
103
  handler: (request, params) => routes[handler](params, request)
103
104
  })),
104
105
  failuresEndpoint(api),
106
+ {
107
+ access: "admin",
108
+ method: "GET",
109
+ path: "/distribution-history",
110
+ handler: async (request) => {
111
+ const query = new URL(request.url).searchParams;
112
+ try {
113
+ const result = await require_model.createInsightsModel(api).getDistributionHistory({
114
+ platform: query.get("platform"),
115
+ channel: query.get("channel") ?? "",
116
+ timeRange: {
117
+ start: Number(query.get("start") ?? NaN),
118
+ end: Number(query.get("end") ?? NaN)
119
+ }
120
+ });
121
+ return Response.json(result, { headers: { "cache-control": "private, no-store" } });
122
+ } catch (error) {
123
+ if (error instanceof _hot_updater_plugin_core.DatabaseAdapterInputError) return Response.json({ error: "Choose a platform, channel, and up to 31 whole UTC days." }, { status: 400 });
124
+ if (!(0, _hot_updater_plugin_core.isDatabaseBusyError)(error)) throw error;
125
+ return Response.json({ error: "Service unavailable" }, {
126
+ status: 503,
127
+ headers: { "retry-after": "5" }
128
+ });
129
+ }
130
+ }
131
+ },
105
132
  {
106
133
  access: "admin",
107
134
  method: "GET",