Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions src/Results/MeasurementCalculations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,23 @@ class MeasurementCalculations {
Object.entries(bandwidthResults)
.map(([bytes, { timings }]) =>
timings.map(
({ bps, duration, ping, measTime, serverTime, transferSize }) => ({
({
bps,
duration,
ping,
measTime,
serverTime,
transferSize,
uploadBytes
}) => ({
bytes: +bytes,
bps,
duration,
ping,
measTime,
serverTime,
transferSize
transferSize,
uploadBytes

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor/style: uploadBytes is now spread onto every bandwidth point, including download points where it is always undefined. That's harmless (optional field), but it does add an explicit uploadBytes: undefined key to download point objects. If you want download points to stay shape-identical to before, you could conditionally include it. Non-blocking.

})
)
)
Expand Down
2 changes: 2 additions & 0 deletions src/engines/BandwidthEngine/BandwidthEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ export interface BandwidthMeasurementTiming {
ping: number;
duration: number;
bps: number | undefined;
/** Server-accepted upload size (bytes) from `cf-meta-upload-bytes`, uploads only. */
uploadBytes?: number;
}

export interface BandwidthTimingResult extends BandwidthMeasurementTiming {
Expand Down
43 changes: 41 additions & 2 deletions src/engines/BandwidthEngine/LoggingBandwidthEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ import type {
} from './BandwidthEngine';
import type { ParallelLatencyOptions } from './ParallelLatency';

export const parseUploadBytesHeader = (
headers: Headers
): number | undefined => {
const value = headers.get('cf-meta-upload-bytes');
if (!value || !/^\d+$/.test(value)) return undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: parseUploadBytesHeader rejects 0 is fine, but note that Number.isSafeInteger already returns false for values exceeding 2^53-1, so the /^\d+$/ regex is the only guard against arbitrarily large strings — good. One edge case to confirm: a value of '0' parses to 0, which is falsy, so getLoggedBytes/#applyUploadBytes (uploadBytes !== undefined) would still treat an accepted size of 0 as valid and report bytes: 0. If the server can legitimately send 0, that's correct; if 0 should mean "no cap", this would mis-report. Worth a quick confirmation.


const bytes = Number(value);
return Number.isSafeInteger(bytes) ? bytes : undefined;
};

/** Upload logs use server-accepted bytes when the server reports a cap. */
export const getLoggedBytes = (
measData: Pick<BandwidthTimingResult, 'type' | 'bytes'>,
uploadBytes: number | undefined
): number =>
measData.type === 'up' && uploadBytes !== undefined
? uploadBytes
: measData.bytes;

export interface LoggingBandwidthEngineOptions extends ParallelLatencyOptions {
measurementId?: string;
logApiUrl?: string;
Expand Down Expand Up @@ -38,8 +57,10 @@ class LoggingBandwidthEngine extends BandwidthEngine {
super.qsParams = logApiUrl ? { measId: this.#measurementId! } : {};
super.responseHook = (r: ResponseHookPayload) =>
this.#loggingResponseHook(r);
super.onMeasurementResult = (meas: BandwidthTimingResult) =>
super.onMeasurementResult = (meas: BandwidthTimingResult) => {
this.#applyUploadBytes(meas);
this.#logMeasurement(meas);
};
}

// Overridden attributes
Expand All @@ -66,6 +87,7 @@ class LoggingBandwidthEngine extends BandwidthEngine {
meas: BandwidthTimingResult,
...restArgs: [BandwidthEngineResults]
) => {
this.#applyUploadBytes(meas);
onMeasurementResult(meas, ...restArgs);
this.#logMeasurement(meas);
};
Expand All @@ -75,11 +97,27 @@ class LoggingBandwidthEngine extends BandwidthEngine {
#measurementId: string | undefined;
#token: string | null | undefined;
#requestTime: number | null | undefined;
#uploadBytes: number | undefined;
#logApiUrl: string | undefined;
#sessionId: string | undefined;

// Internal methods

/**
* Records server-accepted upload bytes on the measurement result so the
* final results payload can report the actual uploaded size (uploads only).
*/
#applyUploadBytes(measData: BandwidthTimingResult): void {
if (measData.type === 'up' && this.#uploadBytes !== undefined) {
measData.uploadBytes = this.#uploadBytes;
}
}

#loggingResponseHook(r: ResponseHookPayload): void {
// Capture server-accepted upload bytes regardless of per-measurement
// logging, so the final results payload can report actual uploaded sizes.
this.#uploadBytes = parseUploadBytesHeader(r.headers);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shared mutable state via #uploadBytes — fragile, please add a guarding comment or assertion.

#uploadBytes is set here synchronously in the response hook, but it is read later in #applyUploadBytes / #logMeasurement, which run inside the setTimeout(...)-deferred onMeasurementResult callback (see BandwidthEngine.#saveMeasurementResults). This is correct today only because bandwidth requests in this engine are strictly sequential and the setTimeout(0) result callback fires well before the next request's network response can overwrite #uploadBytes.

This is an implicit invariant. If request pipelining/concurrency is ever added to BandwidthEngine, the value captured here could be clobbered by a later request before the deferred callback reads it, silently attaching the wrong uploadBytes to a measurement. Consider attaching the parsed value to the per-request flow (e.g. carry it through the timing object) instead of an instance field, or at minimum document this ordering dependency.


if (!this.#logApiUrl) return;

// get request time
Expand All @@ -94,7 +132,7 @@ class LoggingBandwidthEngine extends BandwidthEngine {

const logData = {
type: measData.type,
bytes: measData.bytes,
bytes: getLoggedBytes(measData, this.#uploadBytes),
ping: Math.round(measData.ping), // round to ms
ttfb: Math.round(measData.ttfb), // round to ms
payloadDownloadTime: Math.round(measData.payloadDownloadTime),
Expand All @@ -109,6 +147,7 @@ class LoggingBandwidthEngine extends BandwidthEngine {

this.#token = null;
this.#requestTime = null;
this.#uploadBytes = undefined;

fetch(this.#logApiUrl, {
method: 'POST',
Expand Down
8 changes: 6 additions & 2 deletions src/logging/logFinalResults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,14 @@ const round = (
const latencyPointsParser: ParserFn = durations =>
(durations as number[]).map(d => round(d, 2));

/** Extracts bytes and rounded bps from each bandwidth data point. */
/**
* Extracts bytes and rounded bps from each bandwidth data point. For uploads,
* `bytes` is the server-accepted size (`cf-meta-upload-bytes`) when reported,
* falling back to the requested size; downloads always use the requested size.
*/
const bpsPointsParser: ParserFn = pnts =>
(pnts as BandwidthPoint[]).map(d => ({
bytes: +d.bytes,
bytes: d.uploadBytes ?? +d.bytes,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness / semantics: bytes and bps become internally inconsistent.

Here bytes is reported as the server-accepted size (uploadBytes), but bps (from d.bps) was computed by calcUploadSpeed in BandwidthEngine.ts using the client-requested numBytes, not the accepted size. When the server caps the upload (the whole motivation for cf-meta-upload-bytes), the emitted pair no longer reconciles: a consumer estimating bps ≈ bytes * 8 / duration would get a different number than the reported bps.

Worth confirming this is intended. If the goal is accurate accepted-size throughput, bps should probably be recomputed from uploadBytes; otherwise consider keeping bps derived from the same bytes value you report.

bps: round(d.bps)
}));

Expand Down
14 changes: 14 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ export interface BandwidthTiming {

/** Actual number of bytes transferred (from `PerformanceResourceTiming`). */
transferSize: number;

/**
* Server-accepted upload size (bytes), from the `cf-meta-upload-bytes`
* response header. Present only for upload requests where the server
* reported how much of the body it accepted (e.g. when it caps the upload).
*/
uploadBytes?: number;
}

/**
Expand Down Expand Up @@ -87,6 +94,13 @@ export interface BandwidthPoint {

/** From `PerformanceResourceTiming`. */
transferSize: number;

/**
* Server-accepted upload size (bytes), from the `cf-meta-upload-bytes`
* response header. Present only for upload points where the server reported
* how much of the body it accepted.
*/
uploadBytes?: number;
}

/** Results from a packet-loss measurement via WebRTC TURN relay. */
Expand Down
21 changes: 21 additions & 0 deletions tests/unit/Results/MeasurementCalculations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,27 @@ describe('MeasurementCalculations', () => {
expect(result[0].bytes).toBe(100000);
expect(result[1].bytes).toBe(1000000);
});

it('surfaces server-accepted upload bytes when present', () => {
const calc = createCalc();
const result = calc.getBandwidthPoints({
5000000000: {
timings: [
{
bps: 10e6,
duration: 100,
ping: 12,
measTime: new Date(200),
serverTime: 8,
transferSize: 4000000000,
uploadBytes: 4000000000
}
]
}
});
expect(result[0].bytes).toBe(5000000000);
expect(result[0].uploadBytes).toBe(4000000000);
});
});

describe('getBandwidth', () => {
Expand Down
46 changes: 46 additions & 0 deletions tests/unit/engines/loggingBandwidthEngine.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';

import {
getLoggedBytes,
parseUploadBytesHeader
} from '../../../src/engines/BandwidthEngine/LoggingBandwidthEngine.ts';

describe('LoggingBandwidthEngine', () => {
describe('parseUploadBytesHeader', () => {
it('reads a valid cf-meta-upload-bytes header', () => {
const headers = new Headers({ 'cf-meta-upload-bytes': '4000000000' });

expect(parseUploadBytesHeader(headers)).toBe(4000000000);
});

it('ignores missing or invalid cf-meta-upload-bytes headers', () => {
expect(parseUploadBytesHeader(new Headers())).toBeUndefined();
expect(
parseUploadBytesHeader(new Headers({ 'cf-meta-upload-bytes': '1e3' }))
).toBeUndefined();
expect(
parseUploadBytesHeader(new Headers({ 'cf-meta-upload-bytes': '-1' }))
).toBeUndefined();
});
});

describe('getLoggedBytes', () => {
it('uses server-reported upload bytes for upload measurements', () => {
expect(
getLoggedBytes({ type: 'up', bytes: 5000000000 }, 4000000000)
).toBe(4000000000);
});

it('falls back to measured bytes when the header is absent', () => {
expect(getLoggedBytes({ type: 'up', bytes: 5000000000 }, undefined)).toBe(
5000000000
);
});

it('does not override download measurements', () => {
expect(
getLoggedBytes({ type: 'down', bytes: 5000000000 }, 4000000000)
).toBe(5000000000);
});
});
});