chore(fullstory): serve the snippet as an asset (#220368)

This commit is contained in:
Alejandro Fernández Haro 2025-05-07 23:06:24 +02:00 committed by GitHub
parent e269d04ee0
commit 10d46e5f5e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 24 additions and 232 deletions

View file

@ -26,7 +26,7 @@ snapshots.js
/x-pack/platform/plugins/private/canvas/storybook/build /x-pack/platform/plugins/private/canvas/storybook/build
/x-pack/platform/plugins/private/reporting/server/export_types/printable_pdf/server/lib/pdf/assets/** /x-pack/platform/plugins/private/reporting/server/export_types/printable_pdf/server/lib/pdf/assets/**
/x-pack/platform/plugins/private/reporting/server/export_types/printable_pdf_v2/server/lib/pdf/assets/** /x-pack/platform/plugins/private/reporting/server/export_types/printable_pdf_v2/server/lib/pdf/assets/**
/x-pack/platform/plugins/private/cloud_integrations/cloud_full_story/server/assets/** /x-pack/platform/plugins/private/cloud_integrations/cloud_full_story/public/assets/**
# package overrides # package overrides
/packages/kbn-eslint-config /packages/kbn-eslint-config

View file

@ -31,6 +31,9 @@ describe('Cloud Plugin', () => {
const plugin = new CloudFullStoryPlugin(initContext); const plugin = new CloudFullStoryPlugin(initContext);
const coreSetup = coreMock.createSetup(); const coreSetup = coreMock.createSetup();
jest
.spyOn(coreSetup.http.staticAssets, 'getPluginAssetHref')
.mockReturnValue('/cloudFullStory/assets/fs.js');
const cloud = { ...cloudMock.createSetup(), isCloudEnabled, isElasticStaffOwned }; const cloud = { ...cloudMock.createSetup(), isCloudEnabled, isElasticStaffOwned };
@ -50,7 +53,7 @@ describe('Cloud Plugin', () => {
expect(coreSetup.analytics.registerShipper).toHaveBeenCalled(); expect(coreSetup.analytics.registerShipper).toHaveBeenCalled();
expect(coreSetup.analytics.registerShipper).toHaveBeenCalledWith(expect.anything(), { expect(coreSetup.analytics.registerShipper).toHaveBeenCalledWith(expect.anything(), {
fullStoryOrgId: 'foo', fullStoryOrgId: 'foo',
scriptUrl: '/internal/cloud/100/fullstory.js', scriptUrl: '/cloudFullStory/assets/fs.js',
namespace: 'FSKibana', namespace: 'FSKibana',
captureOnStartup: false, captureOnStartup: false,
}); });
@ -65,7 +68,7 @@ describe('Cloud Plugin', () => {
expect(coreSetup.analytics.registerShipper).toHaveBeenCalledWith(expect.anything(), { expect(coreSetup.analytics.registerShipper).toHaveBeenCalledWith(expect.anything(), {
fullStoryOrgId: 'foo', fullStoryOrgId: 'foo',
pageVarsDebounceTimeMs: 500, pageVarsDebounceTimeMs: 500,
scriptUrl: '/internal/cloud/100/fullstory.js', scriptUrl: '/cloudFullStory/assets/fs.js',
namespace: 'FSKibana', namespace: 'FSKibana',
captureOnStartup: false, captureOnStartup: false,
}); });

View file

@ -7,7 +7,7 @@
import type { import type {
AnalyticsServiceSetup, AnalyticsServiceSetup,
IBasePath, IStaticAssets,
PluginInitializerContext, PluginInitializerContext,
CoreSetup, CoreSetup,
Plugin, Plugin,
@ -17,7 +17,7 @@ import { duration } from 'moment';
interface SetupFullStoryDeps { interface SetupFullStoryDeps {
analytics: AnalyticsServiceSetup; analytics: AnalyticsServiceSetup;
basePath: IBasePath; staticAssets: IStaticAssets;
} }
export interface CloudFullStoryConfig { export interface CloudFullStoryConfig {
@ -45,10 +45,14 @@ export class CloudFullStoryPlugin implements Plugin {
.info('Skipping FullStory setup for a Elastic-owned deployments'); .info('Skipping FullStory setup for a Elastic-owned deployments');
return; return;
} }
this.setupFullStory({ analytics: core.analytics, basePath: core.http.basePath }).catch((e) => this.setupFullStory({
// eslint-disable-next-line no-console analytics: core.analytics,
console.debug(`Error setting up FullStory: ${e.toString()}`) staticAssets: core.http.staticAssets,
); }).catch((e) => {
this.initializerContext.logger
.get()
.debug(`Error setting up FullStory: ${e.toString()}`, { error: e });
});
} }
} }
@ -59,10 +63,10 @@ export class CloudFullStoryPlugin implements Plugin {
/** /**
* If the right config is provided, register the FullStory shipper to the analytics client. * If the right config is provided, register the FullStory shipper to the analytics client.
* @param analytics Core's Analytics service's setup contract. * @param analytics Core's Analytics service's setup contract.
* @param basePath Core's http.basePath helper. * @param staticAssets Core's http.staticAssets helper.
* @private * @private
*/ */
private async setupFullStory({ analytics, basePath }: SetupFullStoryDeps) { private async setupFullStory({ analytics, staticAssets }: SetupFullStoryDeps) {
const { org_id: fullStoryOrgId, eventTypesAllowlist, pageVarsDebounceTime } = this.config; const { org_id: fullStoryOrgId, eventTypesAllowlist, pageVarsDebounceTime } = this.config;
if (!fullStoryOrgId) { if (!fullStoryOrgId) {
return; // do not load any FullStory code in the browser if not enabled return; // do not load any FullStory code in the browser if not enabled
@ -77,15 +81,9 @@ export class CloudFullStoryPlugin implements Plugin {
...(pageVarsDebounceTime ...(pageVarsDebounceTime
? { pageVarsDebounceTimeMs: duration(pageVarsDebounceTime).asMilliseconds() } ? { pageVarsDebounceTimeMs: duration(pageVarsDebounceTime).asMilliseconds() }
: {}), : {}),
/** scriptUrl: staticAssets.getPluginAssetHref('fs.js'),
* FIXME: this should use the {@link IStaticAssets['getPluginAssetHref']}
* function. Then we can avoid registering our own endpoint in this plugin.
*/
scriptUrl: basePath.prepend(
`/internal/cloud/${this.initializerContext.env.packageInfo.buildNum}/fullstory.js`
),
namespace: 'FSKibana', namespace: 'FSKibana',
// Tell FullStory to not capture from the start, and wait for the opt-in confirmation // Tell FullStory to not capture from the start and wait for the opt-in confirmation
captureOnStartup: false, captureOnStartup: false,
}); });
} }

View file

@ -5,11 +5,9 @@
* 2.0. * 2.0.
*/ */
import { PluginInitializerContext } from '@kbn/core/server';
export { config } from './config'; export { config } from './config';
export async function plugin(initializerContext: PluginInitializerContext) { export async function plugin() {
const { CloudFullStoryPlugin } = await import('./plugin'); const { CloudFullStoryPlugin } = await import('./plugin');
return new CloudFullStoryPlugin(initializerContext); return new CloudFullStoryPlugin();
} }

View file

@ -1,12 +0,0 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const registerFullStoryRouteMock = jest.fn();
jest.doMock('./routes', () => ({
registerFullStoryRoute: registerFullStoryRouteMock,
}));

View file

@ -1,33 +0,0 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { coreMock } from '@kbn/core/server/mocks';
import { cloudMock } from '@kbn/cloud-plugin/server/mocks';
import { registerFullStoryRouteMock } from './plugin.test.mock';
import { CloudFullStoryPlugin } from './plugin';
describe('Cloud FullStory plugin', () => {
let plugin: CloudFullStoryPlugin;
beforeEach(() => {
registerFullStoryRouteMock.mockReset();
plugin = new CloudFullStoryPlugin(coreMock.createPluginInitializerContext());
});
test('registers route when cloud is enabled', () => {
plugin.setup(coreMock.createSetup(), {
cloud: { ...cloudMock.createSetup(), isCloudEnabled: true },
});
expect(registerFullStoryRouteMock).toHaveBeenCalledTimes(1);
});
test('does not register the route when cloud is disabled', () => {
plugin.setup(coreMock.createSetup(), {
cloud: { ...cloudMock.createSetup(), isCloudEnabled: false },
});
expect(registerFullStoryRouteMock).not.toHaveBeenCalled();
});
});

View file

@ -5,26 +5,10 @@
* 2.0. * 2.0.
*/ */
import type { PluginInitializerContext, CoreSetup, Plugin } from '@kbn/core/server'; import type { Plugin } from '@kbn/core/server';
import type { CloudSetup } from '@kbn/cloud-plugin/server';
import { registerFullStoryRoute } from './routes';
interface CloudFullStorySetupDeps {
cloud: CloudSetup;
}
export class CloudFullStoryPlugin implements Plugin { export class CloudFullStoryPlugin implements Plugin {
constructor(private readonly initializerContext: PluginInitializerContext) {} public setup() {}
public setup(core: CoreSetup, { cloud }: CloudFullStorySetupDeps) {
if (cloud.isCloudEnabled) {
registerFullStoryRoute({
httpResources: core.http.resources,
packageInfo: this.initializerContext.env.packageInfo,
});
}
}
public start() {} public start() {}

View file

@ -1,62 +0,0 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
jest.mock('fs/promises');
import { renderFullStoryLibraryFactory, FULLSTORY_LIBRARY_PATH } from './fullstory';
import fs from 'fs/promises';
const fsMock = fs as jest.Mocked<typeof fs>;
describe('renderFullStoryLibraryFactory', () => {
beforeEach(() => {
jest.resetAllMocks();
fsMock.readFile.mockResolvedValue(Buffer.from('fake fs src'));
});
afterAll(() => jest.restoreAllMocks());
it('successfully returns file contents', async () => {
const render = renderFullStoryLibraryFactory();
const { body } = await render();
expect(fsMock.readFile).toHaveBeenCalledWith(FULLSTORY_LIBRARY_PATH);
expect(body.toString()).toEqual('fake fs src');
});
it('only reads from file system once callback is invoked', async () => {
const render = renderFullStoryLibraryFactory();
expect(fsMock.readFile).not.toHaveBeenCalled();
await render();
expect(fsMock.readFile).toHaveBeenCalledTimes(1);
});
it('does not read from filesystem on subsequent calls', async () => {
const render = renderFullStoryLibraryFactory();
await render();
expect(fsMock.readFile).toHaveBeenCalledTimes(1);
await render();
expect(fsMock.readFile).toHaveBeenCalledTimes(1);
await render();
expect(fsMock.readFile).toHaveBeenCalledTimes(1);
});
it('returns max-age cache-control in dist', async () => {
const render = renderFullStoryLibraryFactory(true);
const { headers } = await render();
expect(headers).toEqual({
'cache-control': 'max-age=31536000',
});
});
it('returns must-revalidate cache-control and sha1 etag in dev', async () => {
const render = renderFullStoryLibraryFactory(false);
const { headers } = await render();
expect(headers).toEqual({
'cache-control': 'must-revalidate',
etag: '1e02f94b45750ba9284c111d31ae7e59c13b8e6e',
});
});
});

View file

@ -1,76 +0,0 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import path from 'path';
import fs from 'fs/promises';
import { createHash } from 'crypto';
import { once } from 'lodash';
import { HttpResources, HttpResponseOptions, PackageInfo } from '@kbn/core/server';
const MINUTE = 60;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
/** @internal exported for testing */
export const FULLSTORY_LIBRARY_PATH = path.join(__dirname, '..', 'assets', 'fullstory_library.js');
/** @internal exported for testing */
export const renderFullStoryLibraryFactory = (dist = true) =>
once(
async (): Promise<{
body: Buffer;
headers: HttpResponseOptions['headers'];
}> => {
const srcBuffer = await fs.readFile(FULLSTORY_LIBRARY_PATH);
const hash = createHash('sha1'); // eslint-disable-line @kbn/eslint/no_unsafe_hash
hash.update(srcBuffer);
const hashDigest = hash.digest('hex');
return {
body: srcBuffer,
// In dist mode, return a long max-age, otherwise use etag + must-revalidate
headers: dist
? { 'cache-control': `max-age=${DAY * 365}` }
: { 'cache-control': 'must-revalidate', etag: hashDigest },
};
}
);
export const registerFullStoryRoute = ({
httpResources,
packageInfo,
}: {
httpResources: HttpResources;
packageInfo: Readonly<PackageInfo>;
}) => {
const renderFullStoryLibrary = renderFullStoryLibraryFactory(packageInfo.dist);
/**
* Register a custom JS endpoint in order to acheive best caching possible with `max-age` similar to plugin bundles.
*/
httpResources.register(
{
// Use the build number in the URL path to leverage max-age caching on production builds
path: `/internal/cloud/${packageInfo.buildNum}/fullstory.js`,
validate: false,
options: {
authRequired: false,
},
security: { authz: { enabled: false, reason: 'This route serves as a js endpoint' } },
},
async (context, req, res) => {
try {
return res.renderJs(await renderFullStoryLibrary());
} catch (e) {
return res.customError({
body: `Could not load FullStory library from disk due to error: ${e.toString()}`,
statusCode: 500,
});
}
}
);
};

View file

@ -1,8 +0,0 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { registerFullStoryRoute } from './fullstory';