APM RUM integration - number of resources loaded from network (#116923) (#117314)

* add a label with the number of resources actually loaded from network

* code review

* @v code review + tests

* minor review fixes

* update jest

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>

Co-authored-by: Liza Katz <lizka.k@gmail.com>
This commit is contained in:
Kibana Machine 2021-11-03 11:46:52 -04:00 committed by GitHub
parent ba7667ebbf
commit a0e1c46051
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 207 additions and 20 deletions

View file

@ -0,0 +1,47 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export class CachedResourceObserver {
private loaded = {
networkOrDisk: 0,
memory: 0,
};
private observer?: PerformanceObserver;
constructor() {
if (!window.PerformanceObserver) return;
const cb = (entries: PerformanceObserverEntryList) => {
const e = entries.getEntries();
e.forEach((entry: Record<string, any>) => {
if (entry.initiatorType === 'script' || entry.initiatorType === 'link') {
// If the resource is fetched from a local cache, or if it is a cross-origin resource, this property returns zero.
// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/transferSize
if (entry.name.indexOf(window.location.host) > -1 && entry.transferSize === 0) {
this.loaded.memory++;
} else {
this.loaded.networkOrDisk++;
}
}
});
};
this.observer = new PerformanceObserver(cb);
this.observer.observe({
type: 'resource',
buffered: true,
});
}
public getCounts() {
return this.loaded;
}
public destroy() {
this.observer?.disconnect();
}
}

View file

@ -7,9 +7,11 @@
*/
jest.mock('@elastic/apm-rum');
import type { DeeplyMockedKeys } from '@kbn/utility-types/jest';
import type { DeeplyMockedKeys, MockedKeys } from '@kbn/utility-types/jest';
import { init, apm } from '@elastic/apm-rum';
import { ApmSystem } from './apm_system';
import { Subject } from 'rxjs';
import { InternalApplicationStart } from './application/types';
const initMock = init as jest.Mocked<typeof init>;
const apmMock = apm as DeeplyMockedKeys<typeof apm>;
@ -39,6 +41,119 @@ describe('ApmSystem', () => {
expect(apm.addLabels).toHaveBeenCalledWith({ alpha: 'one' });
});
describe('manages the page load transaction', () => {
it('does nothing if theres no transaction', async () => {
const apmSystem = new ApmSystem({ active: true });
const mockTransaction: MockedKeys<Transaction> = {
type: 'wrong',
// @ts-expect-error 2345
block: jest.fn(),
mark: jest.fn(),
};
apmMock.getCurrentTransaction.mockReturnValue(mockTransaction);
await apmSystem.setup();
expect(mockTransaction.mark).not.toHaveBeenCalled();
// @ts-expect-error 2345
expect(mockTransaction.block).not.toHaveBeenCalled();
});
it('blocks a page load transaction', async () => {
const apmSystem = new ApmSystem({ active: true });
const mockTransaction: MockedKeys<Transaction> = {
type: 'page-load',
// @ts-expect-error 2345
block: jest.fn(),
mark: jest.fn(),
};
apmMock.getCurrentTransaction.mockReturnValue(mockTransaction);
await apmSystem.setup();
expect(mockTransaction.mark).toHaveBeenCalledTimes(1);
expect(mockTransaction.mark).toHaveBeenCalledWith('apm-setup');
// @ts-expect-error 2345
expect(mockTransaction.block).toHaveBeenCalledTimes(1);
});
it('marks apm start', async () => {
const apmSystem = new ApmSystem({ active: true });
const currentAppId$ = new Subject<string>();
const mark = jest.fn();
const mockTransaction: MockedKeys<Transaction> = {
type: 'page-load',
mark,
// @ts-expect-error 2345
block: jest.fn(),
end: jest.fn(),
addLabels: jest.fn(),
};
apmMock.getCurrentTransaction.mockReturnValue(mockTransaction);
await apmSystem.setup();
mark.mockReset();
await apmSystem.start({
application: {
currentAppId$,
} as any as InternalApplicationStart,
});
expect(mark).toHaveBeenCalledWith('apm-start');
});
it('closes the page load transaction once', async () => {
const apmSystem = new ApmSystem({ active: true });
const currentAppId$ = new Subject<string>();
const mockTransaction: MockedKeys<Transaction> = {
type: 'page-load',
// @ts-expect-error 2345
block: jest.fn(),
mark: jest.fn(),
end: jest.fn(),
addLabels: jest.fn(),
};
apmMock.getCurrentTransaction.mockReturnValue(mockTransaction);
await apmSystem.setup();
await apmSystem.start({
application: {
currentAppId$,
} as any as InternalApplicationStart,
});
currentAppId$.next('myapp');
expect(mockTransaction.end).toHaveBeenCalledTimes(1);
currentAppId$.next('another-app');
expect(mockTransaction.end).toHaveBeenCalledTimes(1);
});
it('adds resource load labels', async () => {
const apmSystem = new ApmSystem({ active: true });
const currentAppId$ = new Subject<string>();
const mockTransaction: Transaction = {
type: 'page-load',
// @ts-expect-error 2345
block: jest.fn(),
mark: jest.fn(),
end: jest.fn(),
addLabels: jest.fn(),
};
apmMock.getCurrentTransaction.mockReturnValue(mockTransaction);
await apmSystem.setup();
await apmSystem.start({
application: {
currentAppId$,
} as any as InternalApplicationStart,
});
currentAppId$.next('myapp');
expect(mockTransaction.addLabels).toHaveBeenCalledWith({
'loaded-resources': 0,
'cached-resources': 0,
});
});
});
describe('http request normalization', () => {
let windowSpy: any;

View file

@ -8,6 +8,7 @@
import type { ApmBase, AgentConfigOptions } from '@elastic/apm-rum';
import { modifyUrl } from '@kbn/std';
import { CachedResourceObserver } from './apm_resource_counter';
import type { InternalApplicationStart } from './application';
/** "GET protocol://hostname:port/pathname" */
@ -31,17 +32,21 @@ interface StartDeps {
export class ApmSystem {
private readonly enabled: boolean;
private pageLoadTransaction?: Transaction;
private resourceObserver: CachedResourceObserver;
private apm?: ApmBase;
/**
* `apmConfig` would be populated with relevant APM RUM agent
* configuration if server is started with elastic.apm.* config.
*/
constructor(private readonly apmConfig?: ApmConfig, private readonly basePath = '') {
this.enabled = apmConfig != null && !!apmConfig.active;
this.resourceObserver = new CachedResourceObserver();
}
async setup() {
if (!this.enabled) return;
const { init, apm } = await import('@elastic/apm-rum');
this.apm = apm;
const { globalLabels, ...apmConfig } = this.apmConfig!;
if (globalLabels) {
apm.addLabels(globalLabels);
@ -50,36 +55,23 @@ export class ApmSystem {
this.addHttpRequestNormalization(apm);
init(apmConfig);
this.pageLoadTransaction = apm.getCurrentTransaction();
// Keep the page load transaction open until all resources finished loading
if (this.pageLoadTransaction && this.pageLoadTransaction.type === 'page-load') {
// @ts-expect-error 2339
this.pageLoadTransaction.block(true);
this.pageLoadTransaction.mark('apm-setup');
}
// hold page load transaction blocks a transaction implicitly created by init.
this.holdPageLoadTransaction(apm);
}
async start(start?: StartDeps) {
if (!this.enabled || !start) return;
if (this.pageLoadTransaction && this.pageLoadTransaction.type === 'page-load') {
this.pageLoadTransaction.mark('apm-start');
}
this.markPageLoadStart();
/**
* Register listeners for navigation changes and capture them as
* route-change transactions after Kibana app is bootstrapped
*/
start.application.currentAppId$.subscribe((appId) => {
const apmInstance = (window as any).elasticApm;
if (appId && apmInstance && typeof apmInstance.startTransaction === 'function') {
// Close the page load transaction
if (this.pageLoadTransaction && this.pageLoadTransaction.type === 'page-load') {
this.pageLoadTransaction.end();
this.pageLoadTransaction = undefined;
}
apmInstance.startTransaction(`/app/${appId}`, 'route-change', {
if (appId && this.apm) {
this.closePageLoadTransaction();
this.apm.startTransaction(`/app/${appId}`, 'route-change', {
managed: true,
canReuse: true,
});
@ -87,6 +79,39 @@ export class ApmSystem {
});
}
/* Hold the page load transaction open, until all resources actually finish loading */
private holdPageLoadTransaction(apm: ApmBase) {
const transaction = apm.getCurrentTransaction();
// Keep the page load transaction open until all resources finished loading
if (transaction && transaction.type === 'page-load') {
this.pageLoadTransaction = transaction;
// @ts-expect-error 2339 block is a private property of Transaction interface
this.pageLoadTransaction.block(true);
this.pageLoadTransaction.mark('apm-setup');
}
}
/* Close and clear the page load transaction */
private closePageLoadTransaction() {
if (this.pageLoadTransaction) {
const loadCounts = this.resourceObserver.getCounts();
this.pageLoadTransaction.addLabels({
'loaded-resources': loadCounts.networkOrDisk,
'cached-resources': loadCounts.memory,
});
this.resourceObserver.destroy();
this.pageLoadTransaction.end();
this.pageLoadTransaction = undefined;
}
}
private markPageLoadStart() {
if (this.pageLoadTransaction) {
this.pageLoadTransaction.mark('apm-start');
}
}
/**
* Adds an observer to the APM configuration for normalizing transactions of the 'http-request' type to remove the
* hostname, protocol, port, and base path. Allows for coorelating data cross different deployments.