Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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
100 changes: 100 additions & 0 deletions packages/isomorphic/trace/checklyTraceRange.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

const rangeFragmentName = '__checkly_trace_range';
const originalHashFragmentName = '__checkly_trace_hash';

export type ChecklyTraceRange = {
start: number;
end: number;
};

export function traceUriWithChecklyRange(traceUri: string, searchParams: URLSearchParams): string {
const starts = searchParams.getAll('rangeStart');
const ends = searchParams.getAll('rangeEnd');
if (!starts.length && !ends.length)
return traceUri;
if (starts.length !== 1 || ends.length !== 1)
throw new Error('Invalid Checkly trace byte range: rangeStart and rangeEnd must each be provided exactly once.');

const range = parseRange(starts[0], ends[0]);
const { uriWithoutHash, hash } = splitHash(traceUri);
assertHttpUrl(uriWithoutHash);

// The fragment keeps ranges in service worker cache keys and relative snapshot URLs,
// but is never sent to the artifact server. Keep the signed URL before it byte-exact.
const fragment = new URLSearchParams();
fragment.set(rangeFragmentName, `v1:${range.start}-${range.end}`);
if (hash)
fragment.set(originalHashFragmentName, hash);
return `${uriWithoutHash}#${fragment.toString()}`;
}

export function checklyTraceRangeFromUri(traceUri: string): { traceUri: string, range?: ChecklyTraceRange } {
const { uriWithoutHash, hash } = splitHash(traceUri);
const fragment = new URLSearchParams(hash);
const ranges = fragment.getAll(rangeFragmentName);
if (!ranges.length)
return { traceUri };

const originalHashes = fragment.getAll(originalHashFragmentName);
const hasUnexpectedFields = [...fragment.keys()].some(name => name !== rangeFragmentName && name !== originalHashFragmentName);
if (ranges.length !== 1 || originalHashes.length > 1 || hasUnexpectedFields)
throw new Error('Invalid Checkly trace byte range metadata.');

const match = /^v1:(0|[1-9]\d*)-(0|[1-9]\d*)$/.exec(ranges[0]);
if (!match)
throw new Error('Invalid Checkly trace byte range metadata.');
const range = parseRange(match[1], match[2]);
assertHttpUrl(uriWithoutHash);

const originalHash = originalHashes[0];
return {
traceUri: `${uriWithoutHash}${originalHash ? `#${originalHash}` : ''}`,
range,
};
}

function parseRange(startValue: string, endValue: string): ChecklyTraceRange {
if (!/^(0|[1-9]\d*)$/.test(startValue) || !/^(0|[1-9]\d*)$/.test(endValue))
throw new Error('Invalid Checkly trace byte range: rangeStart and rangeEnd must be non-negative integers.');

const start = Number(startValue);
const end = Number(endValue);
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end))
throw new Error('Invalid Checkly trace byte range: rangeStart and rangeEnd must be safe integers.');
if (end < start)
throw new Error('Invalid Checkly trace byte range: rangeEnd must be greater than or equal to rangeStart.');
return { start, end };
}

function assertHttpUrl(traceUri: string): void {
let protocol: string;
try {
protocol = new URL(traceUri).protocol;
} catch {
throw new Error('Invalid Checkly trace URL.');
}
if (protocol !== 'http:' && protocol !== 'https:')
throw new Error('Invalid Checkly trace URL: byte ranges require HTTP or HTTPS.');
}

function splitHash(uri: string): { uriWithoutHash: string, hash: string } {
const hashIndex = uri.indexOf('#');
if (hashIndex === -1)
return { uriWithoutHash: uri, hash: '' };
return { uriWithoutHash: uri.slice(0, hashIndex), hash: uri.slice(hashIndex + 1) };
}
8 changes: 7 additions & 1 deletion packages/trace-viewer/src/sw/traceLoaderBackends.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
// @ts-ignore
import * as zipImport from '@zip.js/zip.js/lib/zip-no-worker-inflate.js';

import { checklyTraceRangeFromUri } from '@isomorphic/trace/checklyTraceRange';
import type * as zip from '@zip.js/zip.js';
import type { TraceLoaderBackend } from '@isomorphic/trace/traceLoader';

Expand All @@ -29,9 +30,14 @@ export class ZipTraceLoaderBackend implements TraceLoaderBackend {

constructor(traceUri: string, progress: Progress) {
zipjs.configure({ baseURL: self.location.href } as any);
const source = checklyTraceRangeFromUri(traceUri);

this._zipReader = new zipjs.ZipReader(
new zipjs.HttpReader(this._resolveTraceURI(traceUri), { mode: 'cors', preventHeadRequest: true } as any),
new zipjs.HttpReader(this._resolveTraceURI(source.traceUri), {
mode: 'cors',
preventHeadRequest: true,
headers: source.range ? { Range: `bytes=${source.range.start}-${source.range.end}` } : undefined,
} as any),
{ useWebWorkers: false });
this._entriesPromise = this._zipReader.getEntries({ onprogress: progress }).then(entries => {
const map = new Map<string, zip.Entry>();
Expand Down
7 changes: 6 additions & 1 deletion packages/trace-viewer/src/ui/workbenchLoader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

import * as React from 'react';
import { traceUriWithChecklyRange } from '@isomorphic/trace/checklyTraceRange';
import { TraceModel } from '@isomorphic/trace/traceModel';
import './workbenchLoader.css';
import { Workbench } from './workbench';
Expand Down Expand Up @@ -121,7 +122,11 @@ export const WorkbenchLoader: React.FunctionComponent<{
testServerConnection.initialize({}).catch(() => {});
} else if (url && !url.startsWith('blob:')) {
// Don't re-use blob file URLs on page load (results in Fetch error)
setTraceURL(url);
try {
setTraceURL(traceUriWithChecklyRange(url, params));
} catch (error) {
setProcessingErrorMessage(error instanceof Error ? error.message : 'Invalid Checkly trace byte range.');
}
}
}, []);

Expand Down
4 changes: 4 additions & 0 deletions packages/trace-viewer/vercel.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"buildCommand": "npm run build --prefix=../.."
}
115 changes: 115 additions & 0 deletions tests/library/trace-viewer-checkly.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import fs from 'fs';

import type { Page } from '@playwright/test';
import type { TraceViewerFixtures } from '../config/traceViewerFixtures';
import { traceViewerFixtures } from '../config/traceViewerFixtures';
import { expect, playwrightTest } from '../config/browserTest';

const test = playwrightTest.extend<TraceViewerFixtures>(traceViewerFixtures);

test.skip(({ trace }) => trace === 'on');
test.skip(process.env.PW_CLOCK === 'frozen');

test('should load different byte ranges from the same HTTP aggregate', async ({ asset, showTraceViewer, server }) => {
const traces = await Promise.all([
fs.promises.readFile(asset('trace-1.31.zip')),
fs.promises.readFile(asset('trace-1.37.zip')),
]);
const prefix = Buffer.from('aggregate-prefix');
const separator = Buffer.from('aggregate-separator');
const aggregate = Buffer.concat([prefix, traces[0], separator, traces[1], Buffer.alloc(66 * 1024)]);
const ranges = [
{ start: prefix.byteLength, end: prefix.byteLength + traces[0].byteLength - 1 },
{ start: prefix.byteLength + traces[0].byteLength + separator.byteLength, end: prefix.byteLength + traces[0].byteLength + separator.byteLength + traces[1].byteLength - 1 },
];
const receivedRanges: string[] = [];
const aggregatePath = '/aggregate.bin?signature=a%2Fb';

server.setRoute(aggregatePath, (request, response) => {
if (request.method === 'OPTIONS') {
response.writeHead(204, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Range',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
});
response.end();
return;
}

const rangeHeader = request.headers.range;
const match = /^bytes=(\d+)-(\d+)$/.exec(rangeHeader ?? '');
if (!match) {
response.writeHead(400);
response.end();
return;
}

receivedRanges.push(rangeHeader!);
const start = Number(match[1]);
const end = Number(match[2]);
const body = aggregate.subarray(start, end + 1);
response.writeHead(206, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Expose-Headers': 'Content-Range',
'Accept-Ranges': 'bytes',
'Content-Range': `bytes ${start}-${end}/${aggregate.byteLength}`,
'Content-Length': body.byteLength,
'Content-Type': 'application/octet-stream',
});
response.end(body);
});

const traceViewer = await showTraceViewer(undefined, { host: 'localhost' });
const viewerUrl = new URL(traceViewer.page.url());
const traceUrl = `${server.PREFIX}${aggregatePath}`;
const openRange = async (page: Page, range: typeof ranges[number], expectedAction: RegExp) => {
const url = new URL(viewerUrl);
url.searchParams.set('trace', traceUrl);
url.searchParams.set('rangeStart', String(range.start));
url.searchParams.set('rangeEnd', String(range.end));
await page.goto(url.toString());
await expect(page.locator('.action-title').filter({ hasText: expectedAction })).toBeVisible();
};

await openRange(traceViewer.page, ranges[0], /click/i);
const snapshot = await traceViewer.snapshotFrame('Click');
await expect(snapshot.locator('[__playwright_target__]')).toHaveText(['Submit']);

await openRange(traceViewer.page, ranges[1], /page\.goto/);

expect(receivedRanges).toEqual(ranges.map(range => `bytes=${range.start}-${range.end}`));
});

test('should reject an incomplete byte range before fetching the aggregate', async ({ showTraceViewer, server }) => {
let requestCount = 0;
server.setRoute('/aggregate.bin', (_request, response) => {
++requestCount;
response.writeHead(500);
response.end();
});

const traceViewer = await showTraceViewer(undefined, { host: 'localhost' });
const url = new URL(traceViewer.page.url());
url.searchParams.set('trace', `${server.PREFIX}/aggregate.bin`);
url.searchParams.set('rangeStart', '0');
await traceViewer.page.goto(url.toString());

await expect(traceViewer.page.getByRole('alert')).toContainText('rangeStart and rangeEnd must each be provided exactly once');
expect(requestCount).toBe(0);
});
61 changes: 61 additions & 0 deletions tests/library/unit/checklyTraceRange.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { expect, test } from '@playwright/test';
import { checklyTraceRangeFromUri, traceUriWithChecklyRange } from '../../../packages/isomorphic/trace/checklyTraceRange';

test('leaves ordinary trace URLs unchanged', () => {
const traceUri = 'https://example.com/trace.zip?signature=a%2Fb#original%2Fhash';
expect(traceUriWithChecklyRange(traceUri, new URLSearchParams())).toBe(traceUri);
expect(checklyTraceRangeFromUri(traceUri)).toEqual({ traceUri });
});

test('round trips a signed URL and byte range without reserializing the URL', () => {
const traceUri = 'https://example.com:443/trace.zip?signature=a%2Fb&empty=#original%2Fhash';
const rangedTraceUri = traceUriWithChecklyRange(traceUri, new URLSearchParams({
rangeStart: '0',
rangeEnd: '1234',
}));

expect(rangedTraceUri).toContain('#__checkly_trace_range=v1%3A0-1234');
expect(checklyTraceRangeFromUri(rangedTraceUri)).toEqual({
traceUri,
range: { start: 0, end: 1234 },
});
});

for (const [name, query] of [
['a missing end', 'rangeStart=1'],
['duplicate values', 'rangeStart=1&rangeStart=2&rangeEnd=3'],
['a negative start', 'rangeStart=-1&rangeEnd=3'],
['a decimal end', 'rangeStart=1&rangeEnd=3.5'],
['leading zeroes', 'rangeStart=01&rangeEnd=3'],
['an unsafe end', 'rangeStart=1&rangeEnd=9007199254740992'],
['an inverted range', 'rangeStart=4&rangeEnd=3'],
] as const) {
test(`rejects ${name}`, () => {
expect(() => traceUriWithChecklyRange('https://example.com/trace.zip', new URLSearchParams(query))).toThrow(/Invalid Checkly trace byte range/);
});
}

test('rejects ranged non-HTTP URLs', () => {
expect(() => traceUriWithChecklyRange('file:///trace.zip', new URLSearchParams('rangeStart=0&rangeEnd=1'))).toThrow(/require HTTP or HTTPS/);
});

test('rejects malformed internal range metadata', () => {
expect(() => checklyTraceRangeFromUri('https://example.com/trace.zip#__checkly_trace_range=v1%3A0-infinity')).toThrow(/Invalid Checkly trace byte range metadata/);
expect(() => checklyTraceRangeFromUri('https://example.com/trace.zip#__checkly_trace_range=v1%3A0-1&unexpected=value')).toThrow(/Invalid Checkly trace byte range metadata/);
});