[8.19] chore(fullstory): serve the snippet as an asset (#220368) (#220482)

# Backport

This will backport the following commits from `main` to `8.19`:
- [chore(fullstory): serve the snippet as an asset
(#220368)](https://github.com/elastic/kibana/pull/220368)

<!--- Backport version: 9.6.6 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sorenlouv/backport)

<!--BACKPORT [{"author":{"name":"Alejandro Fernández
Haro","email":"alejandro.haro@elastic.co"},"sourceCommit":{"committedDate":"2025-05-07T21:06:24Z","message":"chore(fullstory):
serve the snippet as an asset
(#220368)","sha":"10d46e5f5e8d331fb61e83eb52494e47d28ca5b6","branchLabelMapping":{"^v9.1.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["Team:Core","Team:Operations","release_note:skip","backport:version","v9.1.0","v8.19.0"],"title":"chore(fullstory):
serve the snippet as an
asset","number":220368,"url":"https://github.com/elastic/kibana/pull/220368","mergeCommit":{"message":"chore(fullstory):
serve the snippet as an asset
(#220368)","sha":"10d46e5f5e8d331fb61e83eb52494e47d28ca5b6"}},"sourceBranch":"main","suggestedTargetBranches":["8.19"],"targetPullRequestStates":[{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/220368","number":220368,"mergeCommit":{"message":"chore(fullstory):
serve the snippet as an asset
(#220368)","sha":"10d46e5f5e8d331fb61e83eb52494e47d28ca5b6"}},{"branch":"8.19","label":"v8.19.0","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->
This commit is contained in:
Alejandro Fernández Haro 2025-05-08 16:00:11 +02:00 committed by GitHub
parent ef4fe754b9
commit 5cf65046b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 24 additions and 231 deletions

View file

@ -26,7 +26,7 @@ snapshots.js
/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_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
/packages/kbn-eslint-config

View file

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

View file

@ -7,7 +7,7 @@
import type {
AnalyticsServiceSetup,
IBasePath,
IStaticAssets,
PluginInitializerContext,
CoreSetup,
Plugin,
@ -17,7 +17,7 @@ import { duration } from 'moment';
interface SetupFullStoryDeps {
analytics: AnalyticsServiceSetup;
basePath: IBasePath;
staticAssets: IStaticAssets;
}
export interface CloudFullStoryConfig {
@ -45,10 +45,14 @@ export class CloudFullStoryPlugin implements Plugin {
.info('Skipping FullStory setup for a Elastic-owned deployments');
return;
}
this.setupFullStory({ analytics: core.analytics, basePath: core.http.basePath }).catch((e) =>
// eslint-disable-next-line no-console
console.debug(`Error setting up FullStory: ${e.toString()}`)
);
this.setupFullStory({
analytics: core.analytics,
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.
* @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 async setupFullStory({ analytics, basePath }: SetupFullStoryDeps) {
private async setupFullStory({ analytics, staticAssets }: SetupFullStoryDeps) {
const { org_id: fullStoryOrgId, eventTypesAllowlist, pageVarsDebounceTime } = this.config;
if (!fullStoryOrgId) {
return; // do not load any FullStory code in the browser if not enabled
@ -77,15 +81,9 @@ export class CloudFullStoryPlugin implements Plugin {
...(pageVarsDebounceTime
? { pageVarsDebounceTimeMs: duration(pageVarsDebounceTime).asMilliseconds() }
: {}),
/**
* 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`
),
scriptUrl: staticAssets.getPluginAssetHref('fs.js'),
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,
});
}

View file

@ -5,11 +5,9 @@
* 2.0.
*/
import { PluginInitializerContext } from '@kbn/core/server';
export { config } from './config';
export async function plugin(initializerContext: PluginInitializerContext) {
export async function 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.
*/
import type { PluginInitializerContext, CoreSetup, Plugin } from '@kbn/core/server';
import type { CloudSetup } from '@kbn/cloud-plugin/server';
import { registerFullStoryRoute } from './routes';
interface CloudFullStorySetupDeps {
cloud: CloudSetup;
}
import type { Plugin } from '@kbn/core/server';
export class CloudFullStoryPlugin implements Plugin {
constructor(private readonly initializerContext: PluginInitializerContext) {}
public setup(core: CoreSetup, { cloud }: CloudFullStorySetupDeps) {
if (cloud.isCloudEnabled) {
registerFullStoryRoute({
httpResources: core.http.resources,
packageInfo: this.initializerContext.env.packageInfo,
});
}
}
public setup() {}
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,75 +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');
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,
},
},
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';