Skip to content

Commit fcaab71

Browse files
sunnylqmclaude
andcommitted
feat: report update lifecycle events to the update server
Maps local report events (downloadSuccess/errorUpdate/errorSwitchVersion/ markSuccess/rollback) to the server's five aggregate types and fires a single best-effort POST {endpoint}/report/{appKey} — no retry, no fallback race, all failures swallowed, skipped in dev. An incremental patch that failed before the full fallback succeeded is additionally reported as patch_fail with the error detail. Opt out with disableTelemetry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 616e206 commit fcaab71

4 files changed

Lines changed: 248 additions & 0 deletions

File tree

‎src/__tests__/telemetry.test.ts‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { describe, expect, test } from 'bun:test';
2+
import {
3+
MAX_DETAIL_LENGTH,
4+
resolveServerEventHash,
5+
resolveServerEventType,
6+
truncateDetail,
7+
} from '../telemetry';
8+
9+
describe('resolveServerEventType', () => {
10+
test('maps lifecycle events to server aggregate types', () => {
11+
expect(resolveServerEventType('downloadSuccess')).toBe('download_success');
12+
expect(resolveServerEventType('markSuccess')).toBe('mark_success');
13+
expect(resolveServerEventType('rollback')).toBe('rollback');
14+
expect(resolveServerEventType('errorSwitchVersion')).toBe('patch_fail');
15+
});
16+
17+
test('splits errorUpdate by underlying native code', () => {
18+
expect(resolveServerEventType('errorUpdate', 'DOWNLOAD_FAILED')).toBe(
19+
'download_fail',
20+
);
21+
expect(resolveServerEventType('errorUpdate')).toBe('download_fail');
22+
expect(resolveServerEventType('errorUpdate', 'PATCH_FAILED')).toBe(
23+
'patch_fail',
24+
);
25+
});
26+
27+
test('returns undefined for local-only events', () => {
28+
expect(resolveServerEventType('checking')).toBeUndefined();
29+
expect(resolveServerEventType('downloading')).toBeUndefined();
30+
expect(resolveServerEventType('errorChecking')).toBeUndefined();
31+
expect(resolveServerEventType('errorMarkSuccess')).toBeUndefined();
32+
expect(resolveServerEventType('downloadingApk')).toBeUndefined();
33+
});
34+
});
35+
36+
describe('resolveServerEventHash', () => {
37+
test('rollback uses the rolled back version', () => {
38+
expect(
39+
resolveServerEventHash({
40+
serverType: 'rollback',
41+
data: { rolledBackVersion: 'abc' },
42+
currentVersion: 'cur',
43+
}),
44+
).toBe('abc');
45+
});
46+
47+
test('mark_success falls back to the running version', () => {
48+
expect(
49+
resolveServerEventHash({
50+
serverType: 'mark_success',
51+
data: {},
52+
currentVersion: 'cur',
53+
}),
54+
).toBe('cur');
55+
});
56+
57+
test('download events require the target hash', () => {
58+
expect(
59+
resolveServerEventHash({
60+
serverType: 'download_fail',
61+
data: { newVersion: 'xyz' },
62+
currentVersion: 'cur',
63+
}),
64+
).toBe('xyz');
65+
expect(
66+
resolveServerEventHash({
67+
serverType: 'download_fail',
68+
data: {},
69+
currentVersion: 'cur',
70+
}),
71+
).toBe('');
72+
});
73+
});
74+
75+
describe('truncateDetail', () => {
76+
test('caps detail under the server column limit', () => {
77+
expect(truncateDetail('x'.repeat(2000))?.length).toBe(MAX_DETAIL_LENGTH);
78+
expect(truncateDetail('short')).toBe('short');
79+
expect(truncateDetail('')).toBeUndefined();
80+
expect(truncateDetail(undefined)).toBeUndefined();
81+
});
82+
});

‎src/client.ts‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ import {
4141
import i18n from './i18n';
4242
import { toUpdateError, UpdateError, UpdateErrorCode } from './error';
4343
import { dedupeEndpoints, executeEndpointFallback } from './endpoint';
44+
import {
45+
resolveServerEventHash,
46+
resolveServerEventType,
47+
truncateDetail,
48+
} from './telemetry';
4449

4550
/**
4651
* Receives every error the client reports, alongside the report event type.
@@ -130,6 +135,9 @@ export class Pushy {
130135
clientType: 'Pushy' | 'Cresc' = 'Pushy';
131136
lastChecking?: number;
132137
lastRespJson?: Promise<CheckResult>;
138+
// Endpoint that most recently served a successful checkUpdate; telemetry
139+
// reuses it instead of re-running the fallback race.
140+
private lastWorkingEndpoint?: string;
133141

134142
version = cInfo.rnu;
135143
loggerPromise = (() => {
@@ -207,6 +215,8 @@ export class Pushy {
207215
data?: Record<string, string | number>;
208216
}) => {
209217
log(`${type} ${code ? `[${code}] ` : ''}${message}`);
218+
// Fire-and-forget server telemetry; must not wait for the logger below.
219+
this.reportToServer({ type, message, code, data });
210220
if (this.options.logger === noop) {
211221
// Wait briefly for a logger to arrive via setOptions (e.g. the rollback
212222
// report fires in the constructor before the user configures one), but
@@ -242,6 +252,72 @@ export class Pushy {
242252
log('logger error:', e?.message || e);
243253
}
244254
};
255+
/**
256+
* Best-effort lifecycle event reporting to the update server (aggregate
257+
* counts + sampled failure details power the version health view and the
258+
* rollback safety net server-side). Single POST to the last known working
259+
* endpoint, no retry, no fallback race; any failure is swallowed — telemetry
260+
* must never affect the update flow. Opt out with disableTelemetry.
261+
*/
262+
private reportToServer = ({
263+
type,
264+
message = '',
265+
code,
266+
data = {},
267+
}: {
268+
type: EventType;
269+
message?: string;
270+
code?: UpdateErrorCode;
271+
data?: Record<string, string | number>;
272+
}) => {
273+
try {
274+
if (__DEV__ || this.options.disableTelemetry) {
275+
return;
276+
}
277+
const serverType = resolveServerEventType(type, code);
278+
if (!serverType) {
279+
return;
280+
}
281+
const { appKey } = this.options;
282+
const endpoint =
283+
this.lastWorkingEndpoint || this.options.server?.main?.[0];
284+
if (!appKey || !endpoint) {
285+
return;
286+
}
287+
const hash = resolveServerEventHash({ serverType, data, currentVersion });
288+
if (!hash) {
289+
return;
290+
}
291+
const send = (payloadType: typeof serverType, detail?: string) =>
292+
fetchWithTimeout(
293+
`${endpoint}/report/${appKey}`,
294+
{
295+
method: 'POST',
296+
headers: { 'Content-Type': 'application/json' },
297+
body: JSON.stringify({
298+
type: payloadType,
299+
hash,
300+
packageVersion:
301+
this.options.overridePackageVersion || packageVersion,
302+
cInfo,
303+
detail: truncateDetail(detail),
304+
}),
305+
},
306+
DEFAULT_FETCH_TIMEOUT_MS,
307+
).catch((e: any) => {
308+
log('telemetry report failed:', e?.message || e);
309+
});
310+
send(serverType, message || undefined);
311+
// A download that only succeeded after an incremental patch failed is
312+
// still a patch_fail signal server-side (diff quality), carried in
313+
// data.error alongside the downloadSuccess event.
314+
if (serverType === 'download_success' && data.error) {
315+
send('patch_fail', String(data.error));
316+
}
317+
} catch (e: any) {
318+
log('telemetry error:', e?.message || e);
319+
}
320+
};
245321
throwIfEnabled = (e: Error) => {
246322
if (this.options.throwError) {
247323
throw e;
@@ -387,6 +463,7 @@ export class Pushy {
387463
});
388464

389465
log('check endpoint success', endpoint);
466+
this.lastWorkingEndpoint = endpoint;
390467
return value;
391468
};
392469
assertDebug = (matter: string) => {

‎src/telemetry.ts‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import type { UpdateErrorCode } from './error';
2+
import type { EventType } from './type';
3+
4+
// Server-side lifecycle event intake (POST {endpoint}/report/{appKey}); the
5+
// enum must stay in sync with the server's client_event_type. Only these five
6+
// aggregate types exist server-side — everything else stays local-only.
7+
export type ServerEventType =
8+
| 'download_success'
9+
| 'download_fail'
10+
| 'patch_fail'
11+
| 'rollback'
12+
| 'mark_success';
13+
14+
// Failure detail is stored server-side in a VARCHAR(512); stay under it.
15+
export const MAX_DETAIL_LENGTH = 500;
16+
17+
export interface ServerReportPayload {
18+
type: ServerEventType;
19+
hash: string;
20+
packageVersion?: string;
21+
cInfo?: {
22+
rnu?: string;
23+
rn?: string;
24+
os?: string;
25+
uuid?: string;
26+
};
27+
detail?: string;
28+
}
29+
30+
/**
31+
* Maps a local report event to the server-side aggregate type, or undefined
32+
* for events the server does not collect. A DOWNLOAD_FAILED whose underlying
33+
* native code was PATCH_FAILED counts as patch_fail (the download itself
34+
* worked; applying it did not), same for switch-version failures.
35+
*/
36+
export const resolveServerEventType = (
37+
type: EventType,
38+
code?: UpdateErrorCode,
39+
): ServerEventType | undefined => {
40+
switch (type) {
41+
case 'downloadSuccess':
42+
return 'download_success';
43+
case 'errorUpdate':
44+
return code === 'PATCH_FAILED' ? 'patch_fail' : 'download_fail';
45+
case 'errorSwitchVersion':
46+
return 'patch_fail';
47+
case 'markSuccess':
48+
return 'mark_success';
49+
case 'rollback':
50+
return 'rollback';
51+
default:
52+
return undefined;
53+
}
54+
};
55+
56+
/**
57+
* The version hash a server event is about. Server-side rows key on it, so an
58+
* event without one is not reportable (returns '').
59+
*/
60+
export const resolveServerEventHash = ({
61+
serverType,
62+
data,
63+
currentVersion,
64+
}: {
65+
serverType: ServerEventType;
66+
data: Record<string, string | number>;
67+
currentVersion: string;
68+
}): string => {
69+
if (serverType === 'rollback') {
70+
return String(data.rolledBackVersion || '');
71+
}
72+
if (serverType === 'mark_success') {
73+
return String(data.newVersion || currentVersion || '');
74+
}
75+
return String(data.newVersion || '');
76+
};
77+
78+
export const truncateDetail = (detail?: string) => {
79+
if (!detail) {
80+
return undefined;
81+
}
82+
return detail.slice(0, MAX_DETAIL_LENGTH);
83+
};

‎src/type.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,12 @@ export interface ClientOptions {
129129
overridePackageVersion?: string;
130130
/** Maximum number of retry attempts for failed downloads (default: 3) */
131131
maxRetries?: number;
132+
/**
133+
* Disable reporting update lifecycle events (download/patch failures,
134+
* rollback, mark success) to the update server. These aggregate stats power
135+
* the version health view in the console. Default: false (enabled).
136+
*/
137+
disableTelemetry?: boolean;
132138
}
133139

134140
export interface UpdateTestPayload {

0 commit comments

Comments
 (0)