import * as queryString from "query-string"; import type { LighthouseReport, QuickTestSummary, RequestListObject, } from "./ResponseTypes"; import { AnalysisResultObject } from "./ResponseTypes"; import { callApi } from "./callApi"; import { getNgrokTunnelPublicUrl } from "./getNgrokTunnelPublicUrl"; import { inferBuildInfo } from "./inferBuildInfo"; let pollIntervalAdjustmentFactor = 1; type DebugBearRumFilterValue = | string | number | null | string[] | number[] | { from?: number; to?: number }; type DebugBearRumFilterWithType = { value: DebugBearRumFilterValue; type: "is" | "isNot" | "matches" | "doesNotMatch"; }; export class DebugBear { pages: PagesApiClient; projects: ProjectsApiClient; annotations: AnnotationsApiClient; analyses: AnalysesApiClient; quickTests: QuickTestsApiClient; constructor(apiKey = process.env.DEBUGBEAR_API_KEY) { if ( typeof arguments[1] === "object" && arguments[1].pollIntervalAdjustmentFactorForTest ) { pollIntervalAdjustmentFactor = arguments[1].pollIntervalAdjustmentFactorForTest; } if (!apiKey) { throw Error("API key is required to create DebugBear client"); } this.pages = new PagesApiClient(apiKey); this.projects = new ProjectsApiClient(apiKey, this); this.annotations = new AnnotationsApiClient(apiKey, this); this.analyses = new AnalysesApiClient(apiKey); this.quickTests = new QuickTestsApiClient(apiKey); } } class ApiClientObject { toJSON() { return { ...this, _apiClient: undefined, }; } } function arrayToQuery(arr: any[], key: string): string { return arr.map((i) => `${key}[]=` + encodeURIComponent(i)).join("&"); } async function waitForAnalysisResult( analysisId: string | string[], explicitApiKeyParam: string, ) { let hasFinished = false; let timeWaited = 0; let interval = 10000 * pollIntervalAdjustmentFactor; const waitInterval = setInterval( () => { // Keep printing output, because e.g. CircleCI cancels step after 10 mins without output process.stdout.write("."); }, 8 * 60 * 1000, ); const isBulkAnalysis = Array.isArray(analysisId); const apiCallPath = isBulkAnalysis ? "/analysis/bulk/?" + arrayToQuery(analysisId, "id") : "/analysis/" + analysisId; let resp; while (!hasFinished) { await new Promise((resolve) => setTimeout(resolve, interval)); timeWaited += interval; if (timeWaited > 5 * 60 * 1000) { interval = 60000 * pollIntervalAdjustmentFactor; } resp = await callApi({ method: "get", path: apiCallPath, explicitApiKeyParam, }); ({ hasFinished } = resp); if (timeWaited > 45 * 60 * 1000) { clearInterval(waitInterval); throw Error("Waiting for result timed out"); } } clearInterval(waitInterval); return resp; } class ProjectObject extends ApiClientObject { id!: string; name!: string; pages!: PageObject[]; constructor( private _apiClient: DebugBear, data, ) { super(); Object.assign(this, data); } createPage(options: CreatePageOptions) { return this._apiClient.pages.create(this.id, options); } deletePage(pageId: string) { return this._apiClient.pages.delete(pageId); } delete() { return this._apiClient.projects.delete(this.id); } } interface CreatePageOptions { name: string; url: string; testScheduleName?: string; deviceName?: string; region?: ServerRegion; tags?: string[]; advancedSettings?: string[]; } interface GetMetricsOptions { from: Date; to: Date; environments?: "all"; } interface PageObject { id: string; name: string; region: ServerRegion; url: string; tags: []; device: { id: string; name: string; rtt: number; bandwidthKbps: number; formFactor: "mobile" | "desktop"; }; testSchedules: { id: string; name: string; everyNHours: number; times: string[]; days: number[]; }[]; advancedSettings: { name: string; type: string; config: object; }[]; } type ServerRegion = | "us-east" | "us-ca" | "us-west" | "us-central" | "uk" | "australia" | "japan" | "germany" | "brazil" | "finland" | "india" | "singapore" | "canada" | "poland" | "spain" | "italy" | "france" | "netherlands" | "switzerland" | "taiwan" | "israel" | "southafrica" | "qatar" | "hongkong" | "mexico" | "chile" | "southkorea" | "indonesia" | "sweden" | "belgium"; function isRumFilterValueWithType( value: any, ): value is DebugBearRumFilterWithType { return Object.hasOwn(value, "value") && Object.hasOwn(value, "type"); } class ProjectsApiClient extends ApiClientObject { constructor( private apiKey: string, private _apiClient: DebugBear, ) { super(); } async get(projectId: string) { const project = await callApi({ method: "get", path: "/projects/" + projectId, explicitApiKeyParam: this.apiKey, }); return new ProjectObject(this._apiClient, project); } async list() { const res = await callApi({ method: "get", path: "/projects", explicitApiKeyParam: this.apiKey, }); return res.map((project) => { return new ProjectObject(this._apiClient, project); }); } async create({ name }: { name: string }): Promise { const page = await callApi({ method: "post", path: "/projects", body: { name, }, explicitApiKeyParam: this.apiKey, }); return new ProjectObject(this._apiClient, page); } async delete(projectId: string) { return callApi({ method: "delete", path: "/projects/" + projectId, explicitApiKeyParam: this.apiKey, }); } async getPageMetrics( projectId: string, { before }: { before?: Date } = {}, ): Promise<{ page: PageObject; metrics: any }[]> { return callApi({ method: "get", path: "/projects/" + projectId + "/pageMetrics" + (before ? "?before=" + before.toISOString() : ""), explicitApiKeyParam: this.apiKey, }); } private static normalizeFilterValue( value: DebugBearRumFilterValue | DebugBearRumFilterWithType, ): DebugBearRumFilterWithType { if (isRumFilterValueWithType(value)) { return value; } else { return { value, type: "is" }; } } /** * Get RUM metrics for a project with optional filtering and grouping * @param projectId - The project ID * @param options - Query options * @param options.from - Start date for the query * @param options.to - End date for the query * @param options.groupBy - Field to group results by * @param options.maxCategories - The maximum number of categories to return for the groupBy parameter. Default is 20, max is 500. If there are more categories than this, the top categories according to the group by settings (default is number of experiences descending). If there are fewer categories than this, all categories will be returned regardless of other parameters. * @param options.metrics - Array of metrics to return * @param options.orderBy - Whether to order the results by count of experiences in each category ("count") or by the value of the metric being grouped by (e.g. LCP value if grouping by URL) ("value"). Default is "count". * @param options.orderByDirection - Sort direction (asc or desc) default is desc * @param options.filters - Filters to apply to the data. The key is the name of the field to filter by and the value is the value to filter by. Examples: * - `{ urlPath: "/project/2" }` - would only return data for the URL "/project/2" * - `{ urlPath: ["/project/2", "/project/3"] }` - would return data for both "/project/2" and "/project/3" * - `{ urlPath: ["/project/2", "/project/3"], urlPath_type: "isNot" }` - would return data for paths that do not match "/project/2" and "/project/3" * - `{ urlPath: "/project/*\/subPath", urlPath_type: "matches" }` - would return data for paths that match the wildcard pattern "/project/*\/subPath" * - `{ urlPath: { value: ["/project/2", "/project/3"], type: "isNot" } }` - type can also be provided within the metric object. This would return data for paths that do not match "/project/2" and "/project/3" * - `{ lcp: { from: 1000, to: 2000 } }` - would return data for LCP between 1000 and 2000 ms * - `{ lcp: { from: 1000, to: 2000 } }` - would return data for LCP between 1000 and 2000 ms * - `{ lcp: { from: 1000 } }` - would return data for LCP greater than or equal to 1000 ms * - `{ lcp: { to: 2000 } }` - would return data for LCP less than or equal to 2000 ms * - `{ device: ["mobile"] }` - would return data for mobile devices * - `{ urlPath: "/project/2", device: ["mobile"] }` - You can combine multiple filters together to further narrow down the data returned. For example, this would return data for the URL "/project/2" on mobile devices only. * @param options.stat - Statistical aggregation to use (p50, p75, p90, p95, avg) */ async getRumMetrics( projectId: string, { from, to, groupBy, maxCategories, metrics, orderBy, orderByDirection, filters, stat, }: { from?: Date; to?: Date; groupBy?: string; filters?: Record< string, DebugBearRumFilterValue | DebugBearRumFilterWithType >; maxCategories?: number; metrics?: string[]; orderBy?: "count" | "value"; orderByDirection?: "asc" | "desc"; stat?: "p50" | "p75" | "p90" | "p95" | "avg" | "sum"; } = {}, ): Promise { const filtersToApply: any = {}; Object.entries(filters || {}).forEach(([key, unnormalisedValue]) => { const { type, value } = ProjectsApiClient.normalizeFilterValue(unnormalisedValue); if ( typeof value?.["from"] === "number" || typeof value?.["to"] === "number" ) { filtersToApply[key] = `${value["from"]}-${value["to"]}`; } else { filtersToApply[key] = value; } if (type !== "is") { filtersToApply[`${key}_type`] = type; } }); return callApi({ method: "get", path: "/project/" + projectId + "/rumMetrics?" + queryString.stringify({ from: from ? from.toISOString() : undefined, to: to ? to.toISOString() : undefined, groupBy, metrics, stat, maxCategories, orderBy, orderByDirection, ...filtersToApply, }), explicitApiKeyParam: this.apiKey, }); } /** * Get individual RUM page views for a project. Returns up to 5000 of the * most recent page views, ordered by date descending. * @param projectId - The project ID * @param options.from - Start date for the query (must be set together with `to`) * @param options.to - End date for the query (must be set together with `from`) * @param options.count - Maximum number of page views to return (default 5000, max 5000) * @param options.filters - Filters to apply, using the same shape as `getRumMetrics`'s `filters` option (e.g. `{ device: "mobile" }`, `{ urlPath: { value: "/foo", type: "matches" } }`, `{ lcp: { from: 1000, to: 2000 } }`) */ async getRumPageViews( projectId: string, { from, to, count, filters, }: { from?: Date; to?: Date; count?: number; filters?: Record< string, DebugBearRumFilterValue | DebugBearRumFilterWithType >; } = {}, ): Promise { const filtersToApply: any = {}; Object.entries(filters || {}).forEach(([key, unnormalisedValue]) => { const { type, value } = ProjectsApiClient.normalizeFilterValue(unnormalisedValue); if ( typeof value?.["from"] === "number" || typeof value?.["to"] === "number" ) { filtersToApply[key] = `${value["from"]}-${value["to"]}`; } else { filtersToApply[key] = value; } if (type !== "is") { filtersToApply[`${key}_type`] = type; } }); return callApi({ method: "get", path: "/project/" + projectId + "/rumPageViews?" + queryString.stringify({ from: from ? from.toISOString() : undefined, to: to ? to.toISOString() : undefined, count, ...filtersToApply, }), explicitApiKeyParam: this.apiKey, }); } } class PagesApiClient { constructor(private apiKey: string) {} async analyze( pageId: number, options: { commitHash?: string; commitBranch?: string; baseHash?: string; baseBranch?: string; url?: string; baseUrl?: string; path?: string; repoOwner?: string; repoName?: string; buildTitle?: string; cookies?: { name: string; value: string; domain: string; path: string }[]; customHeaders?: { [key: string]: string }; userFlowReplacements?: { [key: string]: string }; inferBuildInfo?: boolean; ngrokWebPort?: number; substituteDocument?: { body?: string; duration?: number; }; runTestNTimes?: number; } = {}, ): Promise<{ url: string; id: string; commitBranch: string | null; commitHash: string | null; buildTitle: string | null; baseHash: string | null; baseBranch: string | null; repoName: string | null; repoOwner: string | null; waitForResult: () => Promise; }> { if (options.inferBuildInfo) { let inferredBuildInfo = await inferBuildInfo({ baseBranch: options.baseBranch, }); options.repoOwner = options.repoOwner || inferredBuildInfo.repoOwner; options.repoName = options.repoName || inferredBuildInfo.repoName; options.commitHash = options.commitHash || inferredBuildInfo.commitHash; options.commitBranch = options.commitBranch || inferredBuildInfo.commitBranch; options.buildTitle = options.buildTitle || inferredBuildInfo.buildTitle; } if (options.ngrokWebPort) { if (options.url) { console.log("Can't take URL parameter if getting baseUrl from ngrok"); process.exit(1); } options.baseUrl = await getNgrokTunnelPublicUrl(options.ngrokWebPort); } const { analysis, resultUrl } = await callApi({ method: "post", path: "/page/" + pageId + "/analyze", body: options, explicitApiKeyParam: this.apiKey, }); return { url: resultUrl, ...analysis, __id: analysis.id, waitForResult: async () => { return waitForAnalysisResult(analysis.id, this.apiKey); }, }; } async analyzeBulk(pageIds: number[]): Promise<{ waitForResult: () => Promise<{ hasFinished: boolean; results: AnalysisResultObject[]; }>; }> { const results = await callApi({ method: "post", path: "/page/analyzeBulk", body: { pageIds }, explicitApiKeyParam: this.apiKey, }); return { waitForResult: async () => { return waitForAnalysisResult( results.map((r) => parseFloat(r.analysis.id)), this.apiKey, ); }, }; } create( projectId: string, { name, url, testScheduleName, deviceName, region, advancedSettings, tags, }: Pick< CreatePageOptions, | "name" | "url" | "testScheduleName" | "deviceName" | "region" | "advancedSettings" | "tags" >, ): Promise { return callApi({ method: "post", path: `/projects/${projectId}/pages`, body: { name, url, testScheduleName, deviceName, region, advancedSettings, tags, }, explicitApiKeyParam: this.apiKey, }); } update( pageId: number, { name, url, testScheduleName, deviceName, region, advancedSettings, tags, }: Partial, ): Promise { return callApi({ method: "patch", path: `/pages/${pageId}`, body: { name, url, testScheduleName, deviceName, region, advancedSettings, tags, }, explicitApiKeyParam: this.apiKey, }); } delete(pageId: string) { return callApi({ method: "delete", path: "/pages/" + pageId, explicitApiKeyParam: this.apiKey, }); } getMetrics( pageId: string, { from, to, environments }: GetMetricsOptions, ): Promise { if (!from) { throw Error("Parameter 'from' is required"); } if (!to) { throw Error("Parameter 'to' is required"); } if (environments && environments !== "all") { throw new Error("Parameter 'environment' must be 'all' or undefined"); } return callApi({ method: "get", path: `/page/${pageId}/metrics?` + queryString.stringify({ from: from.toISOString(), to: to.toISOString(), environments, }), explicitApiKeyParam: this.apiKey, }); } } class AnnotationsApiClient extends ApiClientObject { constructor( private apiKey: string, private _apiClient: DebugBear, ) { super(); } async list(projectId: string) { const res = await callApi({ method: "get", path: `/project/${projectId}/annotations`, explicitApiKeyParam: this.apiKey, }); return res.map((project) => { return new ProjectObject(this._apiClient, project); }); } async create( projectId, { title, description, pageFilter, date, }: { title: string; description: string; pageFilter: string; date: string | Date; }, ) { return await callApi({ method: "post", path: `/project/${projectId}/annotation`, body: { title, description, pageFilter, date: typeof date === "string" ? date : date.toISOString(), }, explicitApiKeyParam: this.apiKey, }); } } class AnalysesApiClient { constructor(private readonly apiKey: string) {} /** * Get the basic info for a specific analysis (status, metrics, build, * commit, etc.). Use this when you already have an analysis ID from an * earlier run, a webhook payload, or a dashboard URL and don't want to * trigger a new test. * @param analysisId The analysis ID * @returns The analysis result object */ async get(analysisId: string | number): Promise { return callApi({ method: "get", path: `/analysis/${analysisId}`, explicitApiKeyParam: this.apiKey, }); } /** * Get the request list for a specific analysis * @param analysisId The analysis ID * @returns Array of request objects with timing and metadata information */ async getRequests(analysisId: string | number): Promise { return callApi({ method: "get", path: `/analysis/${analysisId}/requests`, explicitApiKeyParam: this.apiKey, }); } /** * Get the Lighthouse report (LHR) for a specific analysis * @param analysisId The analysis ID * @returns The full Lighthouse report JSON */ async getLighthouseReport( analysisId: string | number, ): Promise { return callApi({ method: "get", path: `/analysis/${analysisId}/lhr`, explicitApiKeyParam: this.apiKey, }); } } async function waitForQuickTestResults( projectId: string | number, quickTestIds: (string | number)[], apiKey: string, options: { onProgress?: (status: { finished: number; total: number; // Only the QuickTests that transitioned to finished since the // previous poll — useful for one-shot logging on completion // without de-duping in the caller. newlyFinished: QuickTestSummary[]; quickTests: QuickTestSummary[]; }) => void; } = {}, ): Promise<{ hasFinished: boolean; quickTests: QuickTestSummary[] }> { let hasFinished = false; let timeWaited = 0; let interval = 10000 * pollIntervalAdjustmentFactor; // Keep printing output so CI doesn't time out on long runs (matches // the analyses pattern). const waitInterval = setInterval( () => process.stdout.write("."), 8 * 60 * 1000, ); let resp: { hasFinished: boolean; quickTests: QuickTestSummary[] } = { hasFinished: false, quickTests: [], }; const finishedIds = new Set(); try { while (!hasFinished) { await new Promise((resolve) => setTimeout(resolve, interval)); timeWaited += interval; if (timeWaited > 5 * 60 * 1000) { interval = 60000 * pollIntervalAdjustmentFactor; } resp = await callApi({ method: "get", path: `/project/${projectId}/quickTest/bulk?` + quickTestIds.map((id) => `id=${id}`).join("&"), explicitApiKeyParam: apiKey, }); hasFinished = !!resp.hasFinished; if (options.onProgress) { const newlyFinished = resp.quickTests.filter( (qt) => qt.hasFinished && !finishedIds.has(qt.id), ); if (newlyFinished.length > 0) { for (const qt of newlyFinished) { finishedIds.add(qt.id); } options.onProgress({ finished: finishedIds.size, total: resp.quickTests.length, newlyFinished, quickTests: resp.quickTests, }); } } } } finally { clearInterval(waitInterval); } return resp; } class QuickTestsApiClient { constructor(private readonly apiKey: string) {} /** * Trigger one or more quick tests against arbitrary URLs without * adding them to monitoring. Returns the created QuickTest summaries * plus a `waitForResults` helper that polls until all of them finish. * * Each test entry must specify a `url`. `region` defaults to * "us-east"; `device` defaults to the project's "Mobile" device (or * the first configured device if there's no "Mobile") when omitted. * * @param projectId The project to run the quick tests under * @param tests Array of test specs */ async run( projectId: string | number, tests: { url: string; region?: string; device?: string; }[], ): Promise<{ quickTests: QuickTestSummary[]; waitForResults: (options?: { onProgress?: (status: { finished: number; total: number; newlyFinished: QuickTestSummary[]; quickTests: QuickTestSummary[]; }) => void; }) => Promise<{ hasFinished: boolean; quickTests: QuickTestSummary[]; }>; }> { const { quickTests } = await callApi({ method: "post", path: `/project/${projectId}/quickTests`, body: tests, explicitApiKeyParam: this.apiKey, }); const apiKey = this.apiKey; return { quickTests, waitForResults: (options = {}) => waitForQuickTestResults( projectId, quickTests.map((qt: QuickTestSummary) => qt.id), apiKey, options, ), }; } /** * Get the summary for a single quick test by ID. Same shape as the * objects returned by `run` / `list` / `waitForResults`. */ async get( projectId: string | number, quickTestId: string | number, ): Promise { return callApi({ method: "get", path: `/project/${projectId}/quickTest/${quickTestId}`, explicitApiKeyParam: this.apiKey, }); } /** * Get the network request waterfall for a finished quick test. */ async getRequests( projectId: string | number, quickTestId: string | number, ): Promise { return callApi({ method: "get", path: `/project/${projectId}/quickTest/${quickTestId}/requests`, explicitApiKeyParam: this.apiKey, }); } /** * Get the full Lighthouse report (LHR) for a finished quick test. */ async getLighthouseReport( projectId: string | number, quickTestId: string | number, ): Promise { return callApi({ method: "get", path: `/project/${projectId}/quickTest/${quickTestId}/lhr`, explicitApiKeyParam: this.apiKey, }); } /** * List quick tests for a project, most-recent first. The server * returns 100 results by default; pass `limit` to override (max 1000). */ async list( projectId: string | number, options: { limit?: number } = {}, ): Promise { const query = typeof options.limit === "number" ? `?limit=${options.limit}` : ""; const { quickTests } = await callApi({ method: "get", path: `/project/${projectId}/quickTests${query}`, explicitApiKeyParam: this.apiKey, }); return quickTests; } }