Skip to content

Commit 7977cf3

Browse files
authored
Merge pull request #1834 from Dexterp37/send-beacon
Bug 1777182 - Add a `sendBeacon` uploader
2 parents dcf02fc + 74508e5 commit 7977cf3

File tree

14 files changed

+250
-66
lines changed

14 files changed

+250
-66
lines changed

glean/src/core/glean.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -268,8 +268,7 @@ namespace Glean {
268268
Context.applicationId = sanitizeApplicationId(applicationId);
269269

270270
// The configuration constructor will throw in case config has any incorrect prop.
271-
const correctConfig = new Configuration(config);
272-
Context.config = correctConfig;
271+
Context.config = new Configuration(config);
273272

274273
// Pre-set debug options for Glean from browser SessionStorage values.
275274
setDebugOptionsFromSessionStorage();
@@ -283,7 +282,7 @@ namespace Glean {
283282
Context.pingsDatabase = new PingsDatabase();
284283
Context.errorManager = new ErrorManager();
285284

286-
pingUploader = new PingUploadManager(correctConfig, Context.pingsDatabase);
285+
pingUploader = new PingUploadManager(Context.config, Context.pingsDatabase);
287286

288287
Context.initialized = true;
289288

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
// Error to be thrown in case the final ping body is larger than MAX_PING_BODY_SIZE.
6+
export class PingBodyOverflowError extends Error {
7+
constructor(message?: string) {
8+
super(message);
9+
this.name = "PingBodyOverflow";
10+
}
11+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { gzipSync, strToU8 } from "fflate";
6+
import { PingBodyOverflowError } from "./ping_body_overflow_error.js";
7+
8+
/**
9+
* Represents a payload to be transmitted by an uploading mechanism.
10+
*/
11+
export default class PingRequest<BodyType extends string | Uint8Array> {
12+
constructor (
13+
readonly identifier: string,
14+
readonly headers: Record<string, string>,
15+
readonly payload: BodyType,
16+
readonly maxBodySize: number
17+
) {}
18+
19+
asCompressedPayload(): PingRequest<string | Uint8Array> {
20+
// If this is already gzipped, do nothing.
21+
if (this.headers["Content-Encoding"] == "gzip") {
22+
return this;
23+
}
24+
25+
// We prefer using `strToU8` instead of TextEncoder directly,
26+
// because it will polyfill TextEncoder if it's not present in the environment.
27+
// For example, IE doesn't provide TextEncoder.
28+
const encodedBody = strToU8(this.payload as string);
29+
30+
let finalBody: string | Uint8Array;
31+
const headers = this.headers;
32+
try {
33+
finalBody = gzipSync(encodedBody);
34+
headers["Content-Encoding"] = "gzip";
35+
headers["Content-Length"] = finalBody.length.toString();
36+
} catch {
37+
// Fall back to whatever we had.
38+
return this;
39+
}
40+
41+
if (finalBody.length > this.maxBodySize) {
42+
throw new PingBodyOverflowError(
43+
`Body for ping ${this.identifier} exceeds ${this.maxBodySize} bytes. Discarding.`
44+
);
45+
}
46+
47+
return new PingRequest<string | Uint8Array>(this.identifier, headers, finalBody, this.maxBodySize);
48+
}
49+
}
50+

glean/src/core/upload/uploader.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
* License, v. 2.0. If a copy of the MPL was not distributed with this
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44

5+
import type PingRequest from "./ping_request.js";
6+
57
// The timeout, in milliseconds, to use for all operations with the server.
68
export const DEFAULT_UPLOAD_TIMEOUT_MS = 10_000;
79

@@ -51,7 +53,14 @@ export abstract class Uploader {
5153
* @param headers Optional headers to include in the request
5254
* @returns The status code of the response.
5355
*/
54-
abstract post(url: string, body: string | Uint8Array, headers?: Record<string, string>): Promise<UploadResult>;
56+
abstract post(url: string, pingRequest: PingRequest<string | Uint8Array>): Promise<UploadResult>;
57+
58+
/**
59+
* Whether or not this uploader supports submitting custom headers.
60+
*
61+
* @returns whether or not custom headers are supported.
62+
*/
63+
abstract supportsCustomHeaders(): boolean;
5564
}
5665

5766
export default Uploader;

glean/src/core/upload/worker.ts

Lines changed: 21 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22
* License, v. 2.0. If a copy of the MPL was not distributed with this
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44

5-
import { gzipSync, strToU8 } from "fflate";
6-
75
import type { QueuedPing } from "./manager.js";
86
import type Uploader from "./uploader.js";
97
import type { UploadTask } from "./task.js";
@@ -14,17 +12,11 @@ import Policy from "./policy.js";
1412
import { UploadResult, UploadResultStatus } from "./uploader.js";
1513
import { UploadTaskTypes } from "./task.js";
1614
import { GLEAN_VERSION } from "../constants.js";
15+
import { PingBodyOverflowError } from "./ping_body_overflow_error.js";
16+
import PingRequest from "./ping_request.js";
1717

1818
const PING_UPLOAD_WORKER_LOG_TAG = "core.Upload.PingUploadWorker";
1919

20-
// Error to be thrown in case the final ping body is larger than MAX_PING_BODY_SIZE.
21-
class PingBodyOverflowError extends Error {
22-
constructor(message?: string) {
23-
super(message);
24-
this.name = "PingBodyOverflow";
25-
}
26-
}
27-
2820
class PingUploadWorker {
2921
// Whether or not someone is blocking on the currentJob.
3022
isBlocking = false;
@@ -47,10 +39,7 @@ class PingUploadWorker {
4739
* @param ping The ping to include the headers in.
4840
* @returns The updated ping.
4941
*/
50-
private buildPingRequest(ping: QueuedPing): {
51-
headers: Record<string, string>;
52-
payload: string | Uint8Array;
53-
} {
42+
private buildPingRequest(ping: QueuedPing): PingRequest<string | Uint8Array> {
5443
let headers = ping.headers || {};
5544
headers = {
5645
...ping.headers,
@@ -60,33 +49,15 @@ class PingUploadWorker {
6049
};
6150

6251
const stringifiedBody = JSON.stringify(ping.payload);
63-
// We prefer using `strToU8` instead of TextEncoder directly,
64-
// because it will polyfill TextEncoder if it's not present in the environment.
65-
// Environments that don't provide TextEncoder are IE and most importantly QML.
66-
const encodedBody = strToU8(stringifiedBody);
67-
68-
let finalBody: string | Uint8Array;
69-
let bodySizeInBytes: number;
70-
try {
71-
finalBody = gzipSync(encodedBody);
72-
bodySizeInBytes = finalBody.length;
73-
headers["Content-Encoding"] = "gzip";
74-
} catch {
75-
finalBody = stringifiedBody;
76-
bodySizeInBytes = encodedBody.length;
77-
}
7852

79-
if (bodySizeInBytes > this.policy.maxPingBodySize) {
53+
if (stringifiedBody.length > this.policy.maxPingBodySize) {
8054
throw new PingBodyOverflowError(
8155
`Body for ping ${ping.identifier} exceeds ${this.policy.maxPingBodySize}bytes. Discarding.`
8256
);
8357
}
8458

85-
headers["Content-Length"] = bodySizeInBytes.toString();
86-
return {
87-
headers,
88-
payload: finalBody
89-
};
59+
headers["Content-Length"] = stringifiedBody.length.toString();
60+
return new PingRequest(ping.identifier, headers, stringifiedBody, this.policy.maxPingBodySize);
9061
}
9162

9263
/**
@@ -99,12 +70,24 @@ class PingUploadWorker {
9970
try {
10071
const finalPing = this.buildPingRequest(ping);
10172

73+
let safeUploader = this.uploader;
74+
if (!this.uploader.supportsCustomHeaders()) {
75+
// Some options require us to submit custom headers. Unfortunately not all the
76+
// uploaders support them (e.g. `sendBeacon`). In case headers are required, switch
77+
// back to the default uploader that, for now, supports headers.
78+
const needsHeaders = !(
79+
(Context.config.sourceTags === undefined) && (Context.config.debugViewTag === undefined)
80+
);
81+
if (needsHeaders) {
82+
safeUploader = Context.platform.uploader;
83+
}
84+
}
85+
10286
// The POST call has to be asynchronous. Once the API call is triggered,
10387
// we rely on the browser's "keepalive" header.
104-
return this.uploader.post(
88+
return safeUploader.post(
10589
`${this.serverEndpoint}${ping.path}`,
106-
finalPing.payload,
107-
finalPing.headers
90+
finalPing
10891
);
10992
} catch (e) {
11093
log(

glean/src/entry/web.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ import platform from "../platform/browser/web/index.js";
66
import { base } from "./base.js";
77

88
export { default as Uploader, UploadResult, UploadResultStatus } from "../core/upload/uploader.js";
9+
export {default as BrowserSendBeaconUploader} from "../platform/browser/sendbeacon_uploader.js";
910
export default base(platform);

glean/src/platform/browser/uploader.ts renamed to glean/src/platform/browser/fetch_uploader.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,30 +2,34 @@
22
* License, v. 2.0. If a copy of the MPL was not distributed with this
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44

5+
import type PingRequest from "../../core/upload/ping_request.js";
6+
57
import log, { LoggingLevel } from "../../core/log.js";
68
import Uploader from "../../core/upload/uploader.js";
79
import { DEFAULT_UPLOAD_TIMEOUT_MS, UploadResultStatus, UploadResult } from "../../core/upload/uploader.js";
810

9-
const LOG_TAG = "platform.browser.Uploader";
11+
const LOG_TAG = "platform.browser.FetchUploader";
1012

11-
class BrowserUploader extends Uploader {
13+
class BrowserFetchUploader extends Uploader {
1214
timeoutMs: number = DEFAULT_UPLOAD_TIMEOUT_MS;
1315

1416
async post(
1517
url: string,
16-
body: string | Uint8Array,
17-
headers: Record<string, string> = {},
18+
pingRequest: PingRequest<string | Uint8Array>,
1819
keepalive = true
1920
): Promise<UploadResult> {
2021
const controller = new AbortController();
2122
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
2223

24+
// We expect to have a gzipped payload.
25+
const gzipRequest = pingRequest.asCompressedPayload();
26+
2327
let response;
2428
try {
2529
response = await fetch(url.toString(), {
26-
headers,
30+
headers: gzipRequest.headers,
2731
method: "POST",
28-
body: body,
32+
body: gzipRequest.payload,
2933
keepalive,
3034
// Strips any cookies or authorization headers from the request.
3135
credentials: "omit",
@@ -42,7 +46,7 @@ class BrowserUploader extends Uploader {
4246
// Try again without `keepalive`, because that may be the issue.
4347
// This problem was observed in chromium versions below v81.
4448
// See: https://chromium.googlesource.com/chromium/src/+/26d70b36dd1c18244fb17b91d275332c8b73eab3
45-
return this.post(url, body, headers, false);
49+
return this.post(url, gzipRequest, false);
4650
}
4751

4852
// From MDN: "A fetch() promise will reject with a TypeError
@@ -63,6 +67,10 @@ class BrowserUploader extends Uploader {
6367
clearTimeout(timeout);
6468
return new UploadResult(UploadResultStatus.Success, response.status);
6569
}
70+
71+
supportsCustomHeaders(): boolean {
72+
return true;
73+
}
6674
}
6775

68-
export default new BrowserUploader();
76+
export default new BrowserFetchUploader();
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import type PingRequest from "../../core/upload/ping_request.js";
6+
7+
import log, { LoggingLevel } from "../../core/log.js";
8+
import Uploader from "../../core/upload/uploader.js";
9+
import { DEFAULT_UPLOAD_TIMEOUT_MS, UploadResultStatus, UploadResult } from "../../core/upload/uploader.js";
10+
11+
const LOG_TAG = "platform.browser.SendBeaconUploader";
12+
13+
class BrowserSendBeaconUploader extends Uploader {
14+
timeoutMs: number = DEFAULT_UPLOAD_TIMEOUT_MS;
15+
16+
// eslint-disable-next-line @typescript-eslint/require-await
17+
async post(
18+
url: string,
19+
pingRequest: PingRequest<string | Uint8Array>
20+
): Promise<UploadResult> {
21+
// While the most appropriate type would be "application/json",
22+
// using that would cause to send CORS preflight requests. We
23+
// instead send the content as plain text and rely on the backend
24+
// to do the appropriate validation/parsing.
25+
const wasQueued = navigator.sendBeacon(url, pingRequest.payload);
26+
if (wasQueued) {
27+
// Unfortunately we don't know much more other than data was enqueued,
28+
// it is the agent's responsibility to manage the submission. The only
29+
// thing we can do is remove this from our internal queue.
30+
return new UploadResult(UploadResultStatus.Success, 200);
31+
}
32+
log(LOG_TAG, "The `sendBeacon` call was not serviced by the browser. Deleting data.", LoggingLevel.Error);
33+
// If the agent says there's a problem, there's not so much we can do.
34+
return new UploadResult(UploadResultStatus.UnrecoverableFailure);
35+
}
36+
37+
supportsCustomHeaders(): boolean {
38+
return false;
39+
}
40+
}
41+
42+
export default new BrowserSendBeaconUploader();

glean/src/platform/browser/web/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import type Platform from "../../index.js";
66

7-
import uploader from "../uploader.js";
7+
import uploader from "../fetch_uploader.js";
88
import info from "./platform_info.js";
99
import Storage from "./storage.js";
1010

glean/src/platform/test/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* License, v. 2.0. If a copy of the MPL was not distributed with this
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44

5+
import type PingRequest from "../../core/upload/ping_request.js";
56
import type Platform from "../index.js";
67
import type PlatformInfo from "../../core/platform_info.js";
78

@@ -12,10 +13,14 @@ import { UploadResultStatus, UploadResult } from "../../core/upload/uploader.js"
1213

1314
class MockUploader extends Uploader {
1415
// eslint-disable-next-line @typescript-eslint/no-unused-vars
15-
post(_url: string, _body: string | Uint8Array, _headers?: Record<string, string>): Promise<UploadResult> {
16+
post(_url: string, _pingRequest: PingRequest<string | Uint8Array>): Promise<UploadResult> {
1617
const result = new UploadResult(UploadResultStatus.Success, 200);
1718
return Promise.resolve(result);
1819
}
20+
21+
supportsCustomHeaders(): boolean {
22+
return true;
23+
}
1924
}
2025

2126
const MockPlatformInfo: PlatformInfo = {

0 commit comments

Comments
 (0)