Dashboard favorites telemetry (#190706)

## Summary

Add telemetry to favorites feature
https://github.com/elastic/kibana/pull/189285


- Adds UI usage counter telemetry, increase the counter when favorite /
unfavorite is clicked
- Add snapshot telemetry: 
  - total "favorite" object in the deployment 
- total users+spaces count combination who have used the favorites
feature
- avg per user per space (only counts those users who favorited at least
once)
  - max favorites objects per user per space

Unfortunately, for snapshot telemetry, I had to add fields to kibana
mapping. We didn't need them for a feature, but I didn't realize that
will have to add them to a mapping. Not sure if there is a better way
This commit is contained in:
Anton Dosov 2024-08-29 17:01:07 +02:00 committed by GitHub
parent 7d54e4e026
commit ec0230b1cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 317 additions and 26 deletions

View file

@ -28,9 +28,11 @@ import {
FavoriteButton,
} from '@kbn/content-management-favorites-public';
const appName = 'my-app';
const favoriteObjectType = 'dashboard';
const favoritesClient = new FavoritesClient('dashboard', {
const favoritesClient = new FavoritesClient(appName, favoriteObjectType, {
http: core.http,
usageCollection: plugins.usageCollection,
});
// wrap your content with the favorites context provider

View file

@ -12,6 +12,7 @@ import classNames from 'classnames';
import { EuiButtonIcon, euiCanAnimate, EuiThemeComputed } from '@elastic/eui';
import { css } from '@emotion/react';
import { useFavorites, useRemoveFavorite, useAddFavorite } from '../favorites_query';
import { useFavoritesClient } from '../favorites_context';
export interface FavoriteButtonProps {
id: string;
@ -24,6 +25,8 @@ export const FavoriteButton = ({ id, className }: FavoriteButtonProps) => {
const removeFavorite = useRemoveFavorite();
const addFavorite = useAddFavorite();
const favoritesClient = useFavoritesClient();
if (!data) return null;
const isFavorite = data.favoriteIds.includes(id);
@ -40,6 +43,7 @@ export const FavoriteButton = ({ id, className }: FavoriteButtonProps) => {
aria-label={title}
iconType={'starFilled'}
onClick={() => {
favoritesClient?.reportRemoveFavoriteClick();
removeFavorite.mutate({ id });
}}
className={classNames(className, 'cm-favorite-button', {
@ -59,6 +63,7 @@ export const FavoriteButton = ({ id, className }: FavoriteButtonProps) => {
aria-label={title}
iconType={'starEmpty'}
onClick={() => {
favoritesClient?.reportAddFavoriteClick();
addFavorite.mutate({ id });
}}
className={classNames(className, 'cm-favorite-button', {

View file

@ -7,6 +7,7 @@
*/
import type { HttpStart } from '@kbn/core-http-browser';
import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public';
import type { GetFavoritesResponse } from '@kbn/content-management-favorites-server';
export interface FavoritesClientPublic {
@ -15,10 +16,16 @@ export interface FavoritesClientPublic {
removeFavorite({ id }: { id: string }): Promise<GetFavoritesResponse>;
getFavoriteType(): string;
reportAddFavoriteClick(): void;
reportRemoveFavoriteClick(): void;
}
export class FavoritesClient implements FavoritesClientPublic {
constructor(private favoriteObjectType: string, private deps: { http: HttpStart }) {}
constructor(
private readonly appName: string,
private readonly favoriteObjectType: string,
private readonly deps: { http: HttpStart; usageCollection?: UsageCollectionStart }
) {}
public async getFavorites(): Promise<GetFavoritesResponse> {
return this.deps.http.get(`/internal/content_management/favorites/${this.favoriteObjectType}`);
@ -39,4 +46,11 @@ export class FavoritesClient implements FavoritesClientPublic {
public getFavoriteType() {
return this.favoriteObjectType;
}
public reportAddFavoriteClick() {
this.deps.usageCollection?.reportUiCounter(this.appName, 'click', 'add_favorite');
}
public reportRemoveFavoriteClick() {
this.deps.usageCollection?.reportUiCounter(this.appName, 'click', 'remove_favorite');
}
}

View file

@ -22,5 +22,6 @@
"@kbn/core-http-browser",
"@kbn/content-management-favorites-server",
"@kbn/i18n-react",
"@kbn/usage-collection-plugin",
]
}

View file

@ -21,13 +21,19 @@ const schemaV1 = schema.object({
favoriteIds: schema.arrayOf(schema.string()),
});
export const favoritesSavedObjectName = 'favorites';
export const favoritesSavedObjectType: SavedObjectsType = {
name: 'favorites',
name: favoritesSavedObjectName,
hidden: true,
namespaceType: 'single',
mappings: {
dynamic: false,
properties: {},
properties: {
userId: { type: 'keyword' },
type: { type: 'keyword' },
favoriteIds: { type: 'keyword' },
},
},
modelVersions: {
1: {
@ -41,5 +47,22 @@ export const favoritesSavedObjectType: SavedObjectsType = {
create: schemaV1,
},
},
2: {
// the model stays the same, but we added the mappings for the snapshot telemetry needs
changes: [
{
type: 'mappings_addition',
addedMappings: {
userId: { type: 'keyword' },
type: { type: 'keyword' },
favoriteIds: { type: 'keyword' },
},
},
],
schemas: {
forwardCompatibility: schemaV1.extends({}, { unknowns: 'ignore' }),
create: schemaV1,
},
},
},
};

View file

@ -0,0 +1,129 @@
/*
* 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.
*/
import type { CoreSetup } from '@kbn/core-lifecycle-server';
import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server';
import type { estypes } from '@elastic/elasticsearch';
import { favoritesSavedObjectName } from './favorites_saved_object';
interface FavoritesUsage {
[favorite_object_type: string]: {
total: number;
total_users_spaces: number;
avg_per_user_per_space: number;
max_per_user_per_space: number;
};
}
export function registerFavoritesUsageCollection({
core,
usageCollection,
}: {
core: CoreSetup;
usageCollection: UsageCollectionSetup;
}) {
usageCollection.registerCollector(
usageCollection.makeUsageCollector<FavoritesUsage>({
type: 'favorites',
isReady: () => true,
schema: {
DYNAMIC_KEY /* e.g. 'dashboard' */: {
total: {
type: 'long',
_meta: { description: 'Total favorite object count in this deployment' },
},
total_users_spaces: {
type: 'long',
_meta: {
description:
'Total users per space that have favorited an object of this type in this deployment',
},
},
avg_per_user_per_space: {
type: 'double',
_meta: {
description:
'Average favorite objects count of this type per user per space for this deployment, only counts users who have favorited at least one object of this type',
},
},
max_per_user_per_space: {
type: 'long',
_meta: {
description:
'Max favorite objects count of this type per user per space for this deployment',
},
},
},
},
fetch: async (context) => {
const favoritesIndex = await core
.getStartServices()
.then(([{ savedObjects }]) => savedObjects.getIndexForType(favoritesSavedObjectName));
const response = await context.esClient.search<
unknown,
{ types: estypes.AggregationsStringTermsAggregate }
>({
index: favoritesIndex,
size: 0,
_source: false,
filter_path: ['aggregations'],
query: {
bool: {
filter: [
{
term: {
type: 'favorites',
},
},
],
},
},
runtime_mappings: {
number_of_favorites: {
type: 'long',
script: {
source: "emit(doc['favorites.favoriteIds'].length)",
},
},
},
aggs: {
types: {
terms: {
field: 'favorites.type',
},
aggs: {
stats: {
stats: {
field: 'number_of_favorites',
},
},
},
},
},
});
const favoritesUsage: FavoritesUsage = {};
const typesBuckets = (response.aggregations?.types?.buckets ??
[]) as estypes.AggregationsStringTermsBucket[];
typesBuckets.forEach((bucket) => {
favoritesUsage[bucket.key] = {
total: bucket.stats.sum,
total_users_spaces: bucket.stats.count,
avg_per_user_per_space: bucket.stats.avg,
max_per_user_per_space: bucket.stats.max,
};
});
return favoritesUsage;
},
})
);
}

View file

@ -7,8 +7,10 @@
*/
import type { CoreSetup, Logger } from '@kbn/core/server';
import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server';
import { registerFavoritesRoutes } from './favorites_routes';
import { favoritesSavedObjectType } from './favorites_saved_object';
import { registerFavoritesUsageCollection } from './favorites_usage_collection';
export type { GetFavoritesResponse } from './favorites_routes';
@ -18,8 +20,21 @@ export type { GetFavoritesResponse } from './favorites_routes';
*
* @param logger
* @param core
* @param usageCollection
*/
export function registerFavorites({ logger, core }: { core: CoreSetup; logger: Logger }) {
export function registerFavorites({
logger,
core,
usageCollection,
}: {
core: CoreSetup;
logger: Logger;
usageCollection?: UsageCollectionSetup;
}) {
core.savedObjects.registerType(favoritesSavedObjectType);
registerFavoritesRoutes({ core, logger });
if (usageCollection) {
registerFavoritesUsageCollection({ core, usageCollection });
}
}

View file

@ -17,5 +17,7 @@
"@kbn/core",
"@kbn/config-schema",
"@kbn/core-saved-objects-api-server",
"@kbn/core-lifecycle-server",
"@kbn/usage-collection-plugin",
]
}

View file

@ -436,7 +436,11 @@
"updated_by",
"version"
],
"favorites": [],
"favorites": [
"favoriteIds",
"type",
"userId"
],
"file": [
"FileKind",
"Meta",

View file

@ -1484,7 +1484,17 @@
},
"favorites": {
"dynamic": false,
"properties": {}
"properties": {
"favoriteIds": {
"type": "keyword"
},
"type": {
"type": "keyword"
},
"userId": {
"type": "keyword"
}
}
},
"file": {
"dynamic": false,

View file

@ -98,7 +98,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
"event_loop_delays_daily": "01b967e8e043801357503de09199dfa3853bab88",
"exception-list": "4aebc4e61fb5d608cae48eaeb0977e8db21c61a4",
"exception-list-agnostic": "6d3262d58eee28ac381ec9654f93126a58be6f5d",
"favorites": "ef282e9fb5a91df3cc88409a9f86d993fb51a6e9",
"favorites": "a68c7c8ae22eaddcca324d8b3bfc80a94e3eec3a",
"file": "6b65ae5899b60ebe08656fd163ea532e557d3c98",
"file-upload-usage-collection-telemetry": "06e0a8c04f991e744e09d03ab2bd7f86b2088200",
"fileShare": "5be52de1747d249a221b5241af2838264e19aaa1",

View file

@ -6,6 +6,9 @@
"plugin": {
"id": "contentManagement",
"server": true,
"browser": true
"browser": true,
"optionalPlugins": [
"usageCollection"
]
}
}

View file

@ -80,14 +80,15 @@ const setup = () => {
...coreSetup,
http,
},
pluginsSetup: {},
};
};
describe('ContentManagementPlugin', () => {
describe('setup()', () => {
test('should expose the core API', () => {
const { plugin, coreSetup } = setup();
const api = plugin.setup(coreSetup);
const { plugin, coreSetup, pluginsSetup } = setup();
const api = plugin.setup(coreSetup, pluginsSetup);
expect(Object.keys(api).sort()).toEqual(['crud', 'eventBus', 'register']);
expect(api.crud('')).toBe('mockedCrud');
@ -97,8 +98,8 @@ describe('ContentManagementPlugin', () => {
describe('RPC', () => {
test('should create a rpc POST HTTP route on the router', () => {
const { plugin, coreSetup, router } = setup();
plugin.setup(coreSetup);
const { plugin, coreSetup, router, pluginsSetup } = setup();
plugin.setup(coreSetup, pluginsSetup);
const [routeConfig]: Parameters<IRouter['post']> = (router.post as jest.Mock).mock.calls[0];
@ -106,8 +107,8 @@ describe('ContentManagementPlugin', () => {
});
test('should register all the procedures in the RPC service and the route handler must send to each procedure the core request context + the request body as input', async () => {
const { plugin, coreSetup, router } = setup();
plugin.setup(coreSetup);
const { plugin, coreSetup, router, pluginsSetup } = setup();
plugin.setup(coreSetup, pluginsSetup);
const [_, handler]: Parameters<IRouter['post']> = (router.post as jest.Mock).mock.calls[0];
@ -150,8 +151,8 @@ describe('ContentManagementPlugin', () => {
});
test('should return error in custom error format', async () => {
const { plugin, coreSetup, router } = setup();
plugin.setup(coreSetup);
const { plugin, coreSetup, router, pluginsSetup } = setup();
plugin.setup(coreSetup, pluginsSetup);
const [_, handler]: Parameters<IRouter['post']> = (router.post as jest.Mock).mock.calls[0];

View file

@ -59,7 +59,7 @@ export class ContentManagementPlugin
});
}
public setup(core: CoreSetup) {
public setup(core: CoreSetup, plugins: ContentManagementServerSetupDependencies) {
if (this.#eventStream) {
this.#eventStream.setup({ core });
}
@ -75,7 +75,7 @@ export class ContentManagementPlugin
contentRegistry,
});
registerFavorites({ core, logger: this.logger });
registerFavorites({ core, logger: this.logger, usageCollection: plugins.usageCollection });
return {
...coreApi,

View file

@ -7,10 +7,12 @@
*/
import type { Version } from '@kbn/object-versioning';
import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server';
import type { CoreApi, StorageContextGetTransformFn } from './core';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface ContentManagementServerSetupDependencies {}
export interface ContentManagementServerSetupDependencies {
usageCollection?: UsageCollectionSetup;
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface ContentManagementServerStartDependencies {}

View file

@ -17,6 +17,7 @@
"@kbn/saved-objects-settings",
"@kbn/core-http-server",
"@kbn/content-management-favorites-server",
"@kbn/usage-collection-plugin",
"@kbn/object-versioning-utils",
],
"exclude": [

View file

@ -14,5 +14,7 @@ import { DashboardFavoritesService } from './types';
export type DashboardFavoritesServiceFactory = PluginServiceFactory<DashboardFavoritesService>;
export const dashboardFavoritesServiceFactory: DashboardFavoritesServiceFactory = () => {
return new FavoritesClient('dashboard', { http: httpServiceMock.createStartContract() });
return new FavoritesClient('dashboards', 'dashboard', {
http: httpServiceMock.createStartContract(),
});
};

View file

@ -10,6 +10,7 @@ import { FavoritesClient } from '@kbn/content-management-favorites-public';
import { KibanaPluginServiceFactory } from '@kbn/presentation-util-plugin/public';
import { DashboardStartDependencies } from '../../plugin';
import { DashboardFavoritesService } from './types';
import { DASHBOARD_APP_ID, DASHBOARD_CONTENT_ID } from '../../dashboard_constants';
export type DashboardFavoritesServiceFactory = KibanaPluginServiceFactory<
DashboardFavoritesService,
@ -18,6 +19,10 @@ export type DashboardFavoritesServiceFactory = KibanaPluginServiceFactory<
export const dashboardFavoritesServiceFactory: DashboardFavoritesServiceFactory = ({
coreStart,
startPlugins,
}) => {
return new FavoritesClient('dashboard', { http: coreStart.http });
return new FavoritesClient(DASHBOARD_APP_ID, DASHBOARD_CONTENT_ID, {
http: coreStart.http,
usageCollection: startPlugins.usageCollection,
});
};

View file

@ -1,4 +1,36 @@
{
"properties": {
"favorites": {
"properties": {
"DYNAMIC_KEY": {
"properties": {
"total": {
"type": "long",
"_meta": {
"description": "Total favorite object count in this deployment"
}
},
"total_users_spaces": {
"type": "long",
"_meta": {
"description": "Total users per space that have favorited an object of this type in this deployment"
}
},
"avg_per_user_per_space": {
"type": "double",
"_meta": {
"description": "Average favorite objects count of this type per user per space for this deployment, only counts users who have favorited at least one object of this type"
}
},
"max_per_user_per_space": {
"type": "long",
"_meta": {
"description": "Max favorite objects count of this type per user per space for this deployment"
}
}
}
}
}
}
}
}

View file

@ -11,6 +11,7 @@
"../../../typings/**/*",
"schema/oss_plugins.json",
"schema/oss_root.json",
"schema/kbn_packages.json"
],
"kbn_references": [
"@kbn/core",

View file

@ -6,6 +6,11 @@
*/
import expect from '@kbn/expect';
import type { UnencryptedTelemetryPayload } from '@kbn/telemetry-plugin/common/types';
import {
ELASTIC_HTTP_VERSION_HEADER,
X_ELASTIC_INTERNAL_ORIGIN_REQUEST,
} from '@kbn/core-http-common';
import { FtrProviderContext } from '../../ftr_provider_context';
import {
@ -163,6 +168,28 @@ export default function ({ getService }: FtrProviderContext) {
response = await api.favorite({ dashboardId: 'fav1', user: interactiveUser3 });
expect(response.body.favoriteIds).to.eql(['fav1']);
});
// depends on the state from previous test
it('reports favorites stats', async () => {
const { body }: { body: UnencryptedTelemetryPayload } = await getService('supertest')
.post('/internal/telemetry/clusters/_stats')
.set('kbn-xsrf', 'xxx')
.set(ELASTIC_HTTP_VERSION_HEADER, '2')
.set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana')
.send({ unencrypted: true, refreshCache: true })
.expect(200);
// @ts-ignore
const favoritesStats = body[0].stats.stack_stats.kibana.plugins.favorites;
expect(favoritesStats).to.eql({
dashboard: {
total: 3,
total_users_spaces: 3,
avg_per_user_per_space: 1,
max_per_user_per_space: 1,
},
});
});
});
});
}

View file

@ -14,6 +14,7 @@ import ossRootTelemetrySchema from '@kbn/telemetry-plugin/schema/oss_root.json';
import xpackRootTelemetrySchema from '@kbn/telemetry-collection-xpack-plugin/schema/xpack_root.json';
import monitoringRootTelemetrySchema from '@kbn/telemetry-collection-xpack-plugin/schema/xpack_monitoring.json';
import ossPluginsTelemetrySchema from '@kbn/telemetry-plugin/schema/oss_plugins.json';
import ossPackagesTelemetrySchema from '@kbn/telemetry-plugin/schema/kbn_packages.json';
import xpackPluginsTelemetrySchema from '@kbn/telemetry-collection-xpack-plugin/schema/xpack_plugins.json';
import type { UnencryptedTelemetryPayload } from '@kbn/telemetry-plugin/common/types';
import type {
@ -160,7 +161,10 @@ export default function ({ getService }: FtrProviderContext) {
// It's nested because of the way it's collected and declared
monitoringRootTelemetrySchema.properties.monitoringTelemetry.properties.stats.items
);
const plugins = deepmerge(ossPluginsTelemetrySchema, xpackPluginsTelemetrySchema);
const plugins = deepmerge(
deepmerge(ossPluginsTelemetrySchema, ossPackagesTelemetrySchema),
xpackPluginsTelemetrySchema
);
try {
assertTelemetryPayload({ root, plugins }, localXPack);

View file

@ -9,6 +9,7 @@ import expect from '@kbn/expect';
import deepmerge from 'deepmerge';
import ossRootTelemetrySchema from '@kbn/telemetry-plugin/schema/oss_root.json';
import ossPluginsTelemetrySchema from '@kbn/telemetry-plugin/schema/oss_plugins.json';
import ossPackagesTelemetrySchema from '@kbn/telemetry-plugin/schema/kbn_packages.json';
import xpackRootTelemetrySchema from '@kbn/telemetry-collection-xpack-plugin/schema/xpack_root.json';
import xpackPluginsTelemetrySchema from '@kbn/telemetry-collection-xpack-plugin/schema/xpack_plugins.json';
import { assertTelemetryPayload } from '@kbn/telemetry-tools';
@ -56,7 +57,10 @@ export default function ({ getService }: FtrProviderContext) {
it('should pass the schema validation', () => {
const root = deepmerge(ossRootTelemetrySchema, xpackRootTelemetrySchema);
const plugins = deepmerge(ossPluginsTelemetrySchema, xpackPluginsTelemetrySchema);
const plugins = deepmerge(
deepmerge(ossPluginsTelemetrySchema, ossPackagesTelemetrySchema),
xpackPluginsTelemetrySchema
);
try {
assertTelemetryPayload({ root, plugins }, stats);

View file

@ -10,6 +10,7 @@ import deepmerge from 'deepmerge';
import ossRootTelemetrySchema from '@kbn/telemetry-plugin/schema/oss_root.json';
import xpackRootTelemetrySchema from '@kbn/telemetry-collection-xpack-plugin/schema/xpack_root.json';
import ossPluginsTelemetrySchema from '@kbn/telemetry-plugin/schema/oss_plugins.json';
import ossPackagesTelemetrySchema from '@kbn/telemetry-plugin/schema/kbn_packages.json';
import xpackPluginsTelemetrySchema from '@kbn/telemetry-collection-xpack-plugin/schema/xpack_plugins.json';
import { assertTelemetryPayload } from '@kbn/telemetry-tools';
import type { UsageStatsPayloadTestFriendly } from '@kbn/test-suites-xpack/api_integration/services/usage_api';
@ -41,7 +42,10 @@ export default function ({ getService }: FtrProviderContext) {
it('should pass the schema validation (ensures BWC with Classic offering)', () => {
const root = deepmerge(ossRootTelemetrySchema, xpackRootTelemetrySchema);
const plugins = deepmerge(ossPluginsTelemetrySchema, xpackPluginsTelemetrySchema);
const plugins = deepmerge(
deepmerge(ossPluginsTelemetrySchema, ossPackagesTelemetrySchema),
xpackPluginsTelemetrySchema
);
try {
assertTelemetryPayload({ root, plugins }, stats);