| name | kibana-plugin-dev |
| description | Comprehensive knowledge base for Kibana plugin development. Covers plugin architecture, server/client patterns, saved objects, embeddables, UI actions, expressions, state management, logging, HTTP resources, and inter-plugin communication. Use this skill when building, debugging, or maintaining Kibana plugins. |
Kibana Plugin Development
This skill provides comprehensive knowledge for developing Kibana plugins across all major subsystems.
Plugin Architecture Overview
Kibana plugins consist of:
- Server-side (
server/): Routes, saved objects, background tasks
- Client-side (
public/): React UI, embeddables, applications
- Common (
common/): Shared types and constants
Plugin Lifecycle
export class MyPlugin implements Plugin {
setup(core: CoreSetup) {
}
start(core: CoreStart) {
}
stop() {
}
}
Key Principles
- Register everything in
setup(), not start()
- Use
await context.core in route handlers (async since 8.1)
- Export types for other plugins to consume
- Use
common/ for shared code between server and browser
Saved Objects
Saved Objects are Kibana's primary persistence layer for plugin data that needs to be managed, imported/exported, migrated across versions, and scoped to Kibana Spaces. Use Saved Objects when your data belongs to the Kibana application layer (configurations, user-created resources, plugin settings) rather than raw Elasticsearch data.
When to use Saved Objects vs plain ES indices:
- Use Saved Objects when: data needs space-scoping, import/export, version migrations, references to other Kibana objects, or management UI integration
- Use plain ES indices when: data is high-volume, time-series, search-heavy, or doesn't need Kibana management features
Type Registration
Register custom saved object types in the server plugin's setup() method:
import { SavedObjectsType } from '@kbn/core/server';
export const MY_CUSTOM_TYPE = 'my-plugin-config';
export const myCustomType: SavedObjectsType = {
name: MY_CUSTOM_TYPE,
hidden: false,
namespaceType: 'single',
mappings: {
dynamic: false,
properties: {
title: { type: 'text' },
name: { type: 'keyword' },
description: { type: 'text' },
enabled: { type: 'boolean' },
priority: { type: 'integer' },
config: { type: 'object', dynamic: false },
tags: { type: 'keyword' },
created_at: { type: 'date' },
updated_at: { type: 'date' },
created_by: { type: 'keyword' },
},
},
management: {
importableAndExportable: true,
icon: 'gear',
defaultSearchField: 'title',
getTitle(obj) {
return obj.attributes.title || obj.attributes.name;
},
getInAppUrl(obj) {
return {
path: `/app/myPlugin#/config/${obj.id}`,
uiCapabilitiesPath: 'myPlugin.show',
};
},
},
migrations: {
'1.1.0': migrateV1_1_0,
'2.0.0': migrateV2_0_0,
},
};
import { myCustomType } from './saved_objects/my_custom_type';
public setup(core: CoreSetup) {
core.savedObjects.registerType(myCustomType);
}
Namespace Types
| Type | Behavior | Use Case |
|---|
single | Belongs to exactly one Space. Isolated between spaces. | User configs, space-specific dashboards |
multiple | Can be shared across multiple Spaces. | Shared templates, reusable configs |
agnostic | Global — not scoped to any Space. | Global settings, license data, system state |
single is the default and most common choice
multiple requires the multiple-isolated or multiple sharing mode and is more complex to manage
agnostic types are visible everywhere and cannot be restricted to a space
Mappings
Every attribute must be mapped. Kibana uses these mappings to create the Elasticsearch index. Unmapped fields are silently dropped.
mappings: {
dynamic: false,
properties: {
title: { type: 'text' },
description: { type: 'text' },
name: { type: 'keyword' },
status: { type: 'keyword' },
type: { type: 'keyword' },
tags: { type: 'keyword' },
count: { type: 'integer' },
score: { type: 'float' },
size: { type: 'long' },
enabled: { type: 'boolean' },
is_default: { type: 'boolean' },
created_at: { type: 'date' },
updated_at: { type: 'date' },
config: { type: 'object', dynamic: false },
metadata: {
properties: {
version: { type: 'keyword' },
source: { type: 'keyword' },
},
},
labels: { type: 'flattened' },
icon: { type: 'binary' },
},
}
Important: dynamic: false is mandatory for saved object types. Setting it to true risks index mapping explosions from user-supplied data.
Migrations
Migrations transform saved objects when Kibana upgrades. Every time you change the mappings or attribute structure of a saved object type, you must provide a migration.
import { SavedObjectMigrationMap } from '@kbn/core/server';
import { migrateToV1_1_0 } from './to_v1_1_0';
import { migrateToV2_0_0 } from './to_v2_0_0';
export const myTypeMigrations: SavedObjectMigrationMap = {
'1.1.0': migrateToV1_1_0,
'2.0.0': migrateToV2_0_0,
};
import { SavedObjectMigrationFn, SavedObjectUnsanitizedDoc } from '@kbn/core/server';
export const migrateToV1_1_0: SavedObjectMigrationFn = (doc) => {
return {
...doc,
attributes: {
...doc.attributes,
priority: doc.attributes.priority ?? 0,
name: doc.attributes.name ?? doc.attributes.title,
},
};
};
import { SavedObjectMigrationFn } from '@kbn/core/server';
export const migrateToV2_0_0: SavedObjectMigrationFn = (doc) => {
const { oldField, deprecatedSetting, ...rest } = doc.attributes;
return {
...doc,
attributes: {
...rest,
config: {
...(doc.attributes.config || {}),
setting: deprecatedSetting ?? 'default',
},
},
};
};
Migration rules:
- Migrations must be idempotent — they may run multiple times
- Never delete the migration function for a version once released — old documents need it
- Migrations run sequentially from the document's version to the current version
- Always provide defaults for new fields (
?? operator)
- Never throw in a migration — return a best-effort result
- Test migrations with real document shapes from previous versions
CRUD Service Pattern
Create a service class that wraps SavedObjectsClient operations:
import {
SavedObjectsClientContract,
SavedObject,
SavedObjectsFindResponse,
SavedObjectsErrorHelpers,
} from '@kbn/core/server';
import { MY_CUSTOM_TYPE } from '../saved_objects/my_custom_type';
export interface MyConfig {
title: string;
name: string;
description?: string;
enabled: boolean;
priority: number;
tags: string[];
config: Record<string, unknown>;
created_at: string;
updated_at: string;
created_by: string;
}
export class MyConfigService {
constructor(private readonly savedObjectsClient: SavedObjectsClientContract) {}
async create(
attributes: Omit<MyConfig, 'created_at' | 'updated_at'>,
references?: Array<{ id: string; type: string; name: string }>
): Promise<SavedObject<MyConfig>> {
const now = new Date().toISOString();
return this.savedObjectsClient.create<MyConfig>(
MY_CUSTOM_TYPE,
{
...attributes,
created_at: now,
updated_at: now,
},
{ references }
);
}
async get(id: string): Promise<SavedObject<MyConfig>> {
return this.savedObjectsClient.get<MyConfig>(MY_CUSTOM_TYPE, id);
}
async find(params: {
page?: number;
perPage?: number;
search?: string;
searchFields?: string[];
sortField?: string;
sortOrder?: 'asc' | 'desc';
filter?: string;
}): Promise<SavedObjectsFindResponse<MyConfig>> {
return this.savedObjectsClient.find<MyConfig>({
type: MY_CUSTOM_TYPE,
page: params.page ?? 1,
perPage: params.perPage ?? 20,
search: params.search,
searchFields: params.searchFields ?? ['title', 'name', 'description'],
sortField: params.sortField ?? 'updated_at',
sortOrder: params.sortOrder ?? 'desc',
filter: params.filter,
});
}
async update(
id: string,
attributes: Partial<MyConfig>,
references?: Array<{ id: string; type: string; name: string }>
): Promise<SavedObject<MyConfig>> {
return this.savedObjectsClient.update<MyConfig>(
MY_CUSTOM_TYPE,
id,
{
...attributes,
updated_at: new Date().toISOString(),
},
{ references }
);
}
async delete(id: string): Promise<{}> {
return this.savedObjectsClient.delete(MY_CUSTOM_TYPE, id);
}
async bulkCreate(
objects: Array<{
attributes: Omit<MyConfig, 'created_at' | 'updated_at'>;
id?: string;
references?: Array<{ id: string; type: string; name: string }>;
}>
): Promise<SavedObject<MyConfig>[]> {
const now = new Date().toISOString();
const result = await this.savedObjectsClient.bulkCreate<MyConfig>(
objects.map((obj) => ({
type: MY_CUSTOM_TYPE,
id: obj.id,
attributes: {
...obj.attributes,
created_at: now,
updated_at: now,
} as MyConfig,
references: obj.references,
}))
);
return result.saved_objects;
}
async bulkGet(ids: string[]): Promise<SavedObject<MyConfig>[]> {
const result = await this.savedObjectsClient.bulkGet<MyConfig>(
ids.map((id) => ({ id, type: MY_CUSTOM_TYPE }))
);
return result.saved_objects;
}
}
Using the Service in Routes
import { IRouter, Logger } from '@kbn/core/server';
import { schema } from '@kbn/config-schema';
import { MyConfigService } from '../services/my_config_service';
import { MY_CUSTOM_TYPE } from '../saved_objects/my_custom_type';
export function registerConfigRoutes(router: IRouter, logger: Logger) {
router.get(
{
path: '/api/my_plugin/configs',
validate: {
query: schema.object({
page: schema.number({ defaultValue: 1, min: 1 }),
perPage: schema.number({ defaultValue: 20, min: 1, max: 100 }),
search: schema.maybe(schema.string()),
sortField: schema.string({ defaultValue: 'updated_at' }),
sortOrder: schema.oneOf(
[schema.literal('asc'), schema.literal('desc')],
{ defaultValue: 'desc' }
),
}),
},
},
async (context, request, response) => {
try {
const coreContext = await context.core;
const client = coreContext.savedObjects.client;
const service = new MyConfigService(client);
const result = await service.find(request.query);
return response.ok({
body: {
items: result.saved_objects.map((so) => ({
id: so.id,
...so.attributes,
references: so.references,
})),
total: result.total,
page: result.page,
perPage: result.per_page,
},
});
} catch (error) {
logger.error(`Error listing configs: ${error}`);
return response.customError({
statusCode: 500,
body: { message: 'Failed to list configs' },
});
}
}
);
router.get(
{
path: '/api/my_plugin/configs/{id}',
validate: {
params: schema.object({ id: schema.string() }),
},
},
async (context, request, response) => {
try {
const client = (await context.core).savedObjects.client;
const service = new MyConfigService(client);
const result = await service.get(request.params.id);
return response.ok({
body: { id: result.id, ...result.attributes, references: result.references },
});
} catch (error: any) {
if (error?.output?.statusCode === 404) {
return response.notFound({ body: { message: `Config ${request.params.id} not found` } });
}
logger.error(`Error getting config: ${error}`);
return response.customError({ statusCode: 500, body: { message: 'Failed to get config' } });
}
}
);
router.post(
{
path: '/api/my_plugin/configs',
validate: {
body: schema.object({
title: schema.string({ minLength: 1, maxLength: 255 }),
name: schema.string({ minLength: 1, maxLength: 100 }),
description: schema.maybe(schema.string()),
enabled: schema.boolean({ defaultValue: true }),
priority: schema.number({ defaultValue: 0, min: 0 }),
tags: schema.arrayOf(schema.string(), { defaultValue: [] }),
config: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }),
references: schema.maybe(
schema.arrayOf(
schema.object({
id: schema.string(),
type: schema.string(),
name: schema.string(),
})
)
),
}),
},
},
async (context, request, response) => {
try {
const coreContext = await context.core;
const client = coreContext.savedObjects.client;
const user = coreContext.security.authc.getCurrentUser();
const service = new MyConfigService(client);
const { references, ...attributes } = request.body;
const result = await service.create(
{ ...attributes, created_by: user?.username ?? 'unknown' },
references
);
return response.ok({
body: { id: result.id, ...result.attributes },
});
} catch (error) {
logger.error(`Error creating config: ${error}`);
return response.customError({ statusCode: 500, body: { message: 'Failed to create config' } });
}
}
);
router.put(
{
path: '/api/my_plugin/configs/{id}',
validate: {
params: schema.object({ id: schema.string() }),
body: schema.object({
title: schema.maybe(schema.string({ minLength: 1, maxLength: 255 })),
name: schema.maybe(schema.string({ minLength: 1, maxLength: 100 })),
description: schema.maybe(schema.string()),
enabled: schema.maybe(schema.boolean()),
priority: schema.maybe(schema.number({ min: 0 })),
tags: schema.maybe(schema.arrayOf(schema.string())),
config: schema.maybe(schema.recordOf(schema.string(), schema.any())),
references: schema.maybe(
schema.arrayOf(
schema.object({
id: schema.string(),
type: schema.string(),
name: schema.string(),
})
)
),
}),
},
},
async (context, request, response) => {
try {
const client = (await context.core).savedObjects.client;
const service = new MyConfigService(client);
const { references, ...attributes } = request.body;
const result = await service.update(request.params.id, attributes, references);
return response.ok({
body: { id: result.id, ...result.attributes },
});
} catch (error: any) {
if (error?.output?.statusCode === 404) {
return response.notFound({ body: { message: `Config ${request.params.id} not found` } });
}
logger.error(`Error updating config: ${error}`);
return response.customError({ statusCode: 500, body: { message: 'Failed to update config' } });
}
}
);
router.delete(
{
path: '/api/my_plugin/configs/{id}',
validate: {
params: schema.object({ id: schema.string() }),
},
},
async (context, request, response) => {
try {
const client = (await context.core).savedObjects.client;
const service = new MyConfigService(client);
await service.delete(request.params.id);
return response.ok({ body: { success: true } });
} catch (error: any) {
if (error?.output?.statusCode === 404) {
return response.notFound({ body: { message: `Config ${request.params.id} not found` } });
}
logger.error(`Error deleting config: ${error}`);
return response.customError({ statusCode: 500, body: { message: 'Failed to delete config' } });
}
}
);
}
Hidden Types
Hidden saved object types are not visible in the Saved Objects management UI and cannot be accessed via the standard client. Use them for internal plugin state, secrets, or system data.
export const myHiddenType: SavedObjectsType = {
name: 'my-plugin-internal-state',
hidden: true,
namespaceType: 'agnostic',
mappings: {
dynamic: false,
properties: {
state: { type: 'object', dynamic: false },
last_run: { type: 'date' },
},
},
};
To access hidden types, request a client with explicit access:
const client = (await context.core).savedObjects.getClient({
includedHiddenTypes: ['my-plugin-internal-state'],
});
const client = core.savedObjects.createInternalRepository(['my-plugin-internal-state']);
References
Saved object references create trackable links between objects. Kibana uses references for:
- Import/export dependency resolution
- Relocating objects between spaces
- Dashboard drilldowns, panel references
const dashboard = await savedObjectsClient.create(
'my-plugin-widget',
{
title: 'My Widget',
},
{
references: [
{
id: 'some-dashboard-id',
type: 'dashboard',
name: 'linked_dashboard',
},
{
id: 'some-index-pattern-id',
type: 'index-pattern',
name: 'primary_data_view',
},
],
}
);
const widget = await savedObjectsClient.get('my-plugin-widget', widgetId);
const dashboardRef = widget.references.find((ref) => ref.name === 'linked_dashboard');
if (dashboardRef) {
const linkedDashboard = await savedObjectsClient.get('dashboard', dashboardRef.id);
}
Why references instead of embedded IDs:
- When objects are imported into a different space, IDs change — references are automatically remapped
- Kibana tracks the dependency graph for import/export ordering
- The management UI shows relationships between objects
Import/Export
Types with management.importableAndExportable: true can be exported and imported through the Kibana UI or API.
management: {
importableAndExportable: true,
icon: 'gear',
defaultSearchField: 'title',
getTitle(obj) {
return obj.attributes.title;
},
getInAppUrl(obj) {
return {
path: `/app/myPlugin#/config/${obj.id}`,
uiCapabilitiesPath: 'myPlugin.show',
};
},
onImport(savedObject) {
return { warnings: [] };
},
onExport(savedObject) {
return savedObject;
},
},
Server-side export/import APIs (for programmatic use):
const exportStream = await savedObjects.createExporter(savedObjectsClient).exportByTypes({
types: [MY_CUSTOM_TYPE],
hasReference: undefined,
includeReferencesDeep: true,
});
const result = await savedObjects.createImporter(savedObjectsClient).import({
readStream: importStream,
overwrite: true,
createNewCopies: false,
});
Client-Side Usage (Public)
Access saved objects from the public side via the HTTP client (preferred) or the savedObjects client:
const response = await http.get('/api/my_plugin/configs');
const result = await core.savedObjects.client.find({
type: 'my-plugin-config',
perPage: 100,
search: 'keyword',
searchFields: ['title'],
});
For most plugins, use Option 1 — wrap saved object operations in server routes that add validation, authorization, and business logic. Direct client access is fine for simple reads.
Saved Objects Best Practices
- Always set
dynamic: false in mappings to prevent mapping explosions from arbitrary user data
- Use references, not embedded IDs, for links between saved objects
- Write migrations for every mapping change — even adding a new field needs a migration that provides a default
- Make migrations idempotent — they must produce correct output regardless of how many times they run
- Use
namespaceType: 'single' by default — only use multiple or agnostic when you have a clear reason
- Test migrations with fixtures of real documents from previous versions
- Hide internal types — if users shouldn't see or manage the type, set
hidden: true
- Don't over-use saved objects — high-volume data belongs in plain ES indices, not saved objects
- Handle 404s gracefully — saved objects can be deleted by users via the management UI
- Use
created_by tracking — store the username from security.authc.getCurrentUser() for audit trails
Embeddables Framework
Embeddables are reusable, stateful components that can be rendered inside Kibana Dashboards and other container contexts. If your plugin produces visualizations or widgets that users should be able to place on dashboards, you need the Embeddables framework.
When to use Embeddables:
- Your plugin renders content that belongs on dashboards
- You want users to configure and save widget instances
- Your content needs to react to dashboard-level time range, filters, and queries
- You want to support drilldowns or inter-widget communication
When NOT to use Embeddables:
- Your plugin is purely a settings/management page
- Your content is only shown within your plugin's own app
- You just need a standalone React component
Architecture Overview
Dashboard (Container Embeddable)
├── Panel 1: Visualization Embeddable
├── Panel 2: Saved Search Embeddable
├── Panel 3: Your Custom Embeddable ← this is what you build
└── Panel 4: Map Embeddable
Key concepts:
- Embeddable: A stateful unit of content with typed Input and Output
- EmbeddableFactory: Creates embeddable instances, provides metadata for the "Add panel" menu
- EmbeddableInput: Configuration that flows from the container (dashboard) to the embeddable — includes
id, timeRange, filters, query, and your custom fields
- EmbeddableOutput: Data the embeddable exposes back to the container
- Container: A special embeddable that manages child embeddables (Dashboard is a container)
Input and Output Types
import { EmbeddableInput, EmbeddableOutput } from '@kbn/embeddable-plugin/public';
export interface MyWidgetInput extends EmbeddableInput {
indexPattern: string;
metricField: string;
aggregationType: 'avg' | 'sum' | 'min' | 'max' | 'count';
colorThreshold?: number;
savedObjectId?: string;
}
export interface MyWidgetOutput extends EmbeddableOutput {
currentValue?: number;
indexPatternId?: string;
}
Classic Embeddable Class (Kibana < 8.8)
import React from 'react';
import ReactDOM from 'react-dom';
import { Subscription } from 'rxjs';
import { Embeddable, IContainer } from '@kbn/embeddable-plugin/public';
import { CoreStart } from '@kbn/core/public';
import { MyWidgetInput, MyWidgetOutput } from './types';
import { MyWidgetComponent } from './my_widget_component';
export const MY_WIDGET_EMBEDDABLE = 'MY_WIDGET_EMBEDDABLE';
export class MyWidgetEmbeddable extends Embeddable<MyWidgetInput, MyWidgetOutput> {
public readonly type = MY_WIDGET_EMBEDDABLE;
private node: HTMLElement | null = null;
private subscription: Subscription | undefined;
constructor(
input: MyWidgetInput,
private readonly services: { core: CoreStart },
parent?: IContainer
) {
super(input, {}, parent);
}
public render(node: HTMLElement): void {
this.node = node;
this.subscription = this.getInput$().subscribe(() => {
this.renderComponent();
});
this.renderComponent();
}
private renderComponent(): void {
if (!this.node) return;
const input = this.getInput();
ReactDOM.render(
<MyWidgetComponent
indexPattern={input.indexPattern}
metricField={input.metricField}
aggregationType={input.aggregationType}
colorThreshold={input.colorThreshold}
timeRange={input.timeRange}
filters={input.filters}
query={input.query}
http={this.services.core.http}
onValueChange={(value) => {
this.updateOutput({ currentValue: value });
}}
onError={(error) => {
this.updateOutput({ error });
}}
onLoading={(loading) => {
this.updateOutput({ loading });
}}
/>,
this.node
);
}
public reload(): void {
this.renderComponent();
}
public destroy(): void {
super.destroy();
if (this.subscription) {
this.subscription.unsubscribe();
}
if (this.node) {
ReactDOM.unmountComponentAtNode(this.node);
}
}
}
Embeddable Factory
import { i18n } from '@kbn/i18n';
import {
EmbeddableFactoryDefinition,
EmbeddableFactory,
IContainer,
} from '@kbn/embeddable-plugin/public';
import { CoreStart } from '@kbn/core/public';
import {
MyWidgetEmbeddable,
MY_WIDGET_EMBEDDABLE,
} from './my_widget_embeddable';
import { MyWidgetInput, MyWidgetOutput } from './types';
export type MyWidgetEmbeddableFactory = EmbeddableFactory<
MyWidgetInput,
MyWidgetOutput,
MyWidgetEmbeddable
>;
export class MyWidgetEmbeddableFactoryDefinition
implements EmbeddableFactoryDefinition<MyWidgetInput, MyWidgetOutput, MyWidgetEmbeddable>
{
public readonly type = MY_WIDGET_EMBEDDABLE;
public readonly isContainerType = false;
public readonly grouping = [
{
id: 'my_plugin',
getDisplayName: () => 'My Plugin',
getIconType: () => 'logoElastic',
},
];
constructor(private getCore: () => CoreStart) {}
public getDisplayName(): string {
return i18n.translate('myPlugin.embeddable.widget.displayName', {
defaultMessage: 'My Widget',
});
}
public getIconType(): string {
return 'visMetric';
}
public getDescription(): string {
return i18n.translate('myPlugin.embeddable.widget.description', {
defaultMessage: 'Displays a custom metric from your data.',
});
}
public async isEditable(): Promise<boolean> {
return true;
}
public canCreateNew(): boolean {
return true;
}
public async getExplicitInput(): Promise<Partial<MyWidgetInput>> {
return {
indexPattern: 'logs-*',
metricField: 'response_time',
aggregationType: 'avg',
};
}
public async create(
input: MyWidgetInput,
parent?: IContainer
): Promise<MyWidgetEmbeddable> {
return new MyWidgetEmbeddable(input, { core: this.getCore() }, parent);
}
}
Register the Factory
import { CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
import { EmbeddableSetup, EmbeddableStart } from '@kbn/embeddable-plugin/public';
import { MyWidgetEmbeddableFactoryDefinition } from './embeddable/my_widget_factory';
import { MY_WIDGET_EMBEDDABLE } from './embeddable/my_widget_embeddable';
interface MyPluginSetupDeps {
embeddable: EmbeddableSetup;
}
interface MyPluginStartDeps {
embeddable: EmbeddableStart;
}
export class MyPlugin implements Plugin<void, void, MyPluginSetupDeps, MyPluginStartDeps> {
private coreStart: CoreStart | undefined;
public setup(core: CoreSetup<MyPluginStartDeps>, { embeddable }: MyPluginSetupDeps): void {
const factory = new MyWidgetEmbeddableFactoryDefinition(
() => this.coreStart!
);
embeddable.registerEmbeddableFactory(factory.type, factory);
}
public start(core: CoreStart): void {
this.coreStart = core;
}
}
Don't forget to add embeddable to your kibana.jsonc:
{
"plugin": {
"requiredPlugins": ["embeddable"],
"requiredBundles": ["embeddable"]
}
}
The React Component
import React, { useEffect, useState, useMemo } from 'react';
import {
EuiPanel,
EuiText,
EuiLoadingSpinner,
EuiEmptyPrompt,
EuiCallOut,
} from '@elastic/eui';
import { HttpSetup } from '@kbn/core/public';
import { TimeRange, Filter, Query } from '@kbn/es-query';
interface MyWidgetComponentProps {
indexPattern: string;
metricField: string;
aggregationType: string;
colorThreshold?: number;
timeRange?: TimeRange;
filters?: Filter[];
query?: Query;
http: HttpSetup;
onValueChange: (value: number) => void;
onError: (error: Error) => void;
onLoading: (loading: boolean) => void;
}
export const MyWidgetComponent: React.FC<MyWidgetComponentProps> = ({
indexPattern,
metricField,
aggregationType,
colorThreshold,
timeRange,
filters,
query,
http,
onValueChange,
onError,
onLoading,
}) => {
const [value, setValue] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const queryParams = useMemo(
() => ({
indexPattern,
metricField,
aggregationType,
timeRange: timeRange ? JSON.stringify(timeRange) : undefined,
filters: filters ? JSON.stringify(filters) : undefined,
query: query ? JSON.stringify(query) : undefined,
}),
[indexPattern, metricField, aggregationType, timeRange, filters, query]
);
useEffect(() => {
const abortController = new AbortController();
const fetchData = async () => {
try {
setLoading(true);
onLoading(true);
const result = await http.get('/api/my_plugin/metric', {
query: queryParams,
signal: abortController.signal,
});
setValue(result.value);
onValueChange(result.value);
setError(null);
} catch (err: any) {
if (err.name !== 'AbortError') {
setError(err);
onError(err);
}
} finally {
setLoading(false);
onLoading(false);
}
};
fetchData();
return () => {
abortController.abort();
};
}, [queryParams, http, onValueChange, onError, onLoading]);
if (loading) {
return (
<EuiPanel hasShadow={false} style={{ textAlign: 'center', padding: 40 }}>
<EuiLoadingSpinner size="xl" />
</EuiPanel>
);
}
if (error) {
return (
<EuiCallOut title="Error loading widget" color="danger" iconType="alert">
{error.message}
</EuiCallOut>
);
}
if (value === null) {
return (
<EuiEmptyPrompt
iconType="visMetric"
title={<h3>No data</h3>}
body={<p>No data found for the selected time range and filters.</p>}
/>
);
}
const color = colorThreshold && value > colorThreshold ? 'danger' : 'success';
return (
<EuiPanel hasShadow={false} style={{ textAlign: 'center', padding: 20 }}>
<EuiText size="s" color="subdued">
{aggregationType.toUpperCase()} of {metricField}
</EuiText>
<EuiText color={color} style={{ fontSize: 48, fontWeight: 700 }}>
{value.toLocaleString()}
</EuiText>
</EuiPanel>
);
};
React Embeddable Pattern (Kibana 8.8+)
Newer Kibana versions introduce a simpler React-first embeddable pattern:
import React, { useState, useEffect } from 'react';
import { ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public';
import { EuiPanel, EuiText, EuiLoadingSpinner } from '@elastic/eui';
export const MY_REACT_EMBEDDABLE_TYPE = 'MY_REACT_WIDGET';
interface MyReactWidgetState {
indexPattern: string;
metricField: string;
aggregationType: string;
}
export const myReactWidgetFactory: ReactEmbeddableFactory<MyReactWidgetState> = {
type: MY_REACT_EMBEDDABLE_TYPE,
deserializeState: (state) => {
return state.rawState as MyReactWidgetState;
},
buildEmbeddable: async (state, buildApi, uuid, parentApi) => {
const api = buildApi(
{
serializeState: () => ({
rawState: state,
}),
},
{
indexPattern: [() => state.indexPattern, (val: string) => { state.indexPattern = val; }],
metricField: [() => state.metricField, (val: string) => { state.metricField = val; }],
}
);
return {
api,
Component: () => {
const [value, setValue] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch(`/api/my_plugin/metric?index=${state.indexPattern}&field=${state.metricField}`)
.then((res) => res.json())
.then((data) => {
setValue(data.value);
setLoading(false);
});
}, []);
if (loading) return <EuiLoadingSpinner />;
return (
<EuiPanel hasShadow={false} style={{ textAlign: 'center' }}>
<EuiText style={{ fontSize: 48 }}>{value}</EuiText>
</EuiPanel>
);
},
};
},
};
Register with:
embeddable.registerReactEmbeddableFactory(MY_REACT_EMBEDDABLE_TYPE, async () => myReactWidgetFactory);
Embeddable with Saved Object
If your embeddable's configuration is persisted as a saved object:
public savedObjectMetaData = {
name: i18n.translate('myPlugin.embeddable.savedObject.name', {
defaultMessage: 'My Widget',
}),
type: 'my-plugin-widget-config',
getIconForSavedObject: () => 'visMetric',
};
public async createFromSavedObject(
savedObjectId: string,
input: Partial<MyWidgetInput>,
parent?: IContainer
): Promise<MyWidgetEmbeddable> {
const savedObject = await this.getCore().savedObjects.client.get(
'my-plugin-widget-config',
savedObjectId
);
return new MyWidgetEmbeddable(
{
...input,
...savedObject.attributes,
savedObjectId,
} as MyWidgetInput,
{ core: this.getCore() },
parent
);
}
This enables the Dashboard's "Add from library" flow — users can select from previously saved widget configurations.
Embeddable Best Practices
- Always implement
destroy() — unmount React, unsubscribe observables, cancel pending requests
- React to input changes — subscribe to
getInput$() and re-render when timeRange, filters, or query change
- Use
AbortController — cancel in-flight requests when input changes or the embeddable is destroyed
- Handle all states — loading, error, empty, and success
- Minimize re-renders — use
distinctUntilChanged() on input subscriptions, memoize derived values
- Register during
setup() — factories must exist before dashboards load
- Support
reload() — dashboards call this when the user hits refresh
- Use EUI — embeddables inherit the dashboard's theme, EUI ensures consistency
- Keep embeddables lightweight — heavy logic should live in server routes, not in the embeddable component
- Test with the Dashboard — embed your component in a real dashboard to verify time range, filters, and query propagation work correctly
UI Actions & Triggers
UI Actions is Kibana's inter-plugin communication system. It connects user interactions (clicks, selections, context menus) to executable behaviors — across plugin boundaries. Dashboard panels use it for context menus, drilldowns, click handlers, and panel badges.
When to use UI Actions:
- Adding items to dashboard panel context menus ("..." menu)
- Reacting to user clicks on visualizations (value clicks, range selections)
- Creating drilldowns between dashboard panels and your plugin
- Emitting custom events from your components that other plugins can react to
- Adding badges or notifications to dashboard panel headers
Core Concepts
Trigger (event) → Action (handler)
↑ ↑
Fired by Registered by
any plugin any plugin
Example:
CONTEXT_MENU_TRIGGER → OpenInMyPluginAction
VALUE_CLICK_TRIGGER → FilterByValueAction
MY_CUSTOM_TRIGGER → ShowDetailFlyoutAction
A Trigger is an event type (e.g. "user clicked a value"). An Action is a handler (e.g. "open a flyout"). Multiple actions can attach to one trigger. Multiple triggers can fire one action.
Defining a Custom Trigger
import { Trigger } from '@kbn/ui-actions-plugin/public';
export const MY_PLUGIN_ROW_CLICK_TRIGGER = 'MY_PLUGIN_ROW_CLICK_TRIGGER';
export const myRowClickTrigger: Trigger = {
id: MY_PLUGIN_ROW_CLICK_TRIGGER,
title: 'My Plugin Row Click',
description: 'Fires when a user clicks a row in My Plugin tables.',
};
Register in setup:
import { myRowClickTrigger, MY_PLUGIN_ROW_CLICK_TRIGGER } from './triggers/my_row_click_trigger';
public setup(core: CoreSetup, { uiActions }: MyPluginSetupDeps) {
uiActions.registerTrigger(myRowClickTrigger);
}
Fire the trigger from your component:
const { uiActions } = useKibana().services;
const handleRowClick = (item: MyItem) => {
uiActions.getTrigger(MY_PLUGIN_ROW_CLICK_TRIGGER).exec({
itemId: item.id,
itemType: item.type,
indexPattern: item.indexPattern,
});
};
Creating an Action
import { Action, createAction } from '@kbn/ui-actions-plugin/public';
import { CoreStart } from '@kbn/core/public';
export const OPEN_DETAIL_ACTION = 'MY_PLUGIN_OPEN_DETAIL_ACTION';
interface OpenDetailContext {
itemId: string;
itemType: string;
}
export function createOpenDetailAction(getCore: () => CoreStart): Action<OpenDetailContext> {
return createAction<OpenDetailContext>({
id: OPEN_DETAIL_ACTION,
type: OPEN_DETAIL_ACTION,
getDisplayName: () => 'Open in My Plugin',
getIconType: () => 'inspect',
isCompatible: async (context: OpenDetailContext) => {
return Boolean(context.itemId && context.itemType === 'config');
},
execute: async (context: OpenDetailContext) => {
const core = getCore();
core.application.navigateToApp('myPlugin', {
path: `/detail/${context.itemId}`,
});
},
getHref: async (context: OpenDetailContext) => {
return `/app/myPlugin#/detail/${context.itemId}`;
},
});
}
Action Class Pattern (for complex actions)
import React from 'react';
import { Action } from '@kbn/ui-actions-plugin/public';
import { CoreStart } from '@kbn/core/public';
import { toMountPoint } from '@kbn/react-kibana-mount';
export const SHOW_FLYOUT_ACTION = 'MY_PLUGIN_SHOW_FLYOUT_ACTION';
interface FlyoutContext {
embeddable?: { type: string };
data?: { id: string; title: string };
}
export class ShowFlyoutAction implements Action<FlyoutContext> {
public readonly id = SHOW_FLYOUT_ACTION;
public readonly type = SHOW_FLYOUT_ACTION;
public readonly order = 100;
constructor(private readonly getCore: () => CoreStart) {}
public getDisplayName(): string {
return 'Show details';
}
public getIconType(): string {
return 'eye';
}
public async isCompatible(context: FlyoutContext): Promise<boolean> {
return context.data?.id !== undefined;
}
public async execute(context: FlyoutContext): Promise<void> {
const core = this.getCore();
const flyoutSession = core.overlays.openFlyout(
toMountPoint(
<DetailFlyout
itemId={context.data!.id}
title={context.data!.title}
http={core.http}
onClose={() => flyoutSession.close()}
/>
),
{
size: 'm',
'data-test-subj': 'myPluginDetailFlyout',
ownFocus: true,
}
);
}
}
Attaching Actions to Triggers
import { UiActionsSetup } from '@kbn/ui-actions-plugin/public';
import { CONTEXT_MENU_TRIGGER, VALUE_CLICK_TRIGGER } from '@kbn/ui-actions-plugin/public';
import { createOpenDetailAction, OPEN_DETAIL_ACTION } from './actions/open_detail_action';
import { ShowFlyoutAction, SHOW_FLYOUT_ACTION } from './actions/show_flyout_action';
import { myRowClickTrigger, MY_PLUGIN_ROW_CLICK_TRIGGER } from './triggers/my_row_click_trigger';
export class MyPlugin {
private coreStart: CoreStart | undefined;
public setup(core: CoreSetup, { uiActions }: { uiActions: UiActionsSetup }) {
uiActions.registerTrigger(myRowClickTrigger);
const openDetailAction = createOpenDetailAction(() => this.coreStart!);
const showFlyoutAction = new ShowFlyoutAction(() => this.coreStart!);
uiActions.registerAction(openDetailAction);
uiActions.registerAction(showFlyoutAction);
uiActions.attachAction(CONTEXT_MENU_TRIGGER, OPEN_DETAIL_ACTION);
uiActions.attachAction(VALUE_CLICK_TRIGGER, SHOW_FLYOUT_ACTION);
uiActions.attachAction(MY_PLUGIN_ROW_CLICK_TRIGGER, OPEN_DETAIL_ACTION);
uiActions.attachAction(MY_PLUGIN_ROW_CLICK_TRIGGER, SHOW_FLYOUT_ACTION);
}
public start(core: CoreStart) {
this.coreStart = core;
}
}
Context Menu Action on Dashboard Panels
The most common use case — adding an item to the "..." menu on dashboard panels:
import { CONTEXT_MENU_TRIGGER } from '@kbn/ui-actions-plugin/public';
import { isFilterableEmbeddable } from '@kbn/embeddable-plugin/public';
const analyzeAction = createAction({
id: 'MY_PLUGIN_ANALYZE_PANEL',
type: 'MY_PLUGIN_ANALYZE_PANEL',
getDisplayName: () => 'Analyze in My Plugin',
getIconType: () => 'inspect',
order: 50,
isCompatible: async ({ embeddable }) => {
return embeddable?.type === 'visualization' || embeddable?.type === 'lens';
},
execute: async ({ embeddable }) => {
const input = embeddable.getInput();
const filters = input.filters || [];
const timeRange = input.timeRange;
core.application.navigateToApp('myPlugin', {
path: `/analyze?filters=${encodeURIComponent(JSON.stringify(filters))}&timeRange=${encodeURIComponent(JSON.stringify(timeRange))}`,
});
},
});
uiActions.registerAction(analyzeAction);
uiActions.attachAction(CONTEXT_MENU_TRIGGER, analyzeAction.id);
Value Click to Filter
React to clicks on visualization values:
import { VALUE_CLICK_TRIGGER } from '@kbn/ui-actions-plugin/public';
const filterByValueAction = createAction({
id: 'MY_PLUGIN_FILTER_BY_VALUE',
type: 'MY_PLUGIN_FILTER_BY_VALUE',
getDisplayName: () => 'Filter by this value',
getIconType: () => 'filter',
isCompatible: async (context) => {
return Boolean(context.data?.data?.length);
},
execute: async (context) => {
const { data } = context;
const filters = await dataPlugin.actions.createFiltersFromValueClickAction({
data: data.data,
});
dataPlugin.query.filterManager.addFilters(filters);
},
});
UI Actions Best Practices
- Always implement
isCompatible() — actions that return true for everything clutter every context menu
- Use
order to control menu position — higher values appear first
- Actions are singletons — one instance handles all invocations, don't store per-invocation state on the instance
- Use
getHref() for navigating actions — enables right-click "open in new tab"
- Clean up overlays — if your action opens a flyout/modal, provide a way to close it
- Keep
execute() fast — show loading indicators for async work
- Custom triggers are powerful — they let other plugins extend your UI without modifying your code
- Test with real dashboards — context menu visibility depends on the trigger context and
isCompatible() logic
- Prefix IDs with your plugin name to avoid collisions
- Don't use UI Actions for simple in-component state — they're for cross-plugin/cross-component communication
Expressions Framework
Kibana's Expressions framework is a pipeline-based execution engine that powers Canvas, Lens, and Dashboard visualizations. Expression functions are composable, stateless transformations that chain together: essql 'SELECT * FROM logs' | mapColumn name='status_label' expression={...} | render type='my_chart'.
When to use Expressions:
- Creating custom visualizations for Canvas or Lens
- Building reusable data transformations
- Rendering custom chart types on dashboards
- Creating server-side data processing functions that can also run in the browser
When NOT to use Expressions:
- Building standard CRUD UIs (use routes + React components)
- One-off data fetching (use the HTTP client or ES client directly)
- Complex stateful workflows (expressions are stateless and functional)
Expression Function
An expression function takes an input, applies arguments, and produces an output:
import { ExpressionFunctionDefinition } from '@kbn/expressions-plugin/common';
import { Datatable } from '@kbn/expressions-plugin/common';
interface MyMetricArguments {
column: string;
operation: 'avg' | 'sum' | 'min' | 'max' | 'count';
decimals: number;
}
interface MyMetricResult {
type: 'my_metric_result';
value: number;
label: string;
operation: string;
}
export const myMetricFunction: ExpressionFunctionDefinition<
'my_metric',
Datatable,
MyMetricArguments,
MyMetricResult
> = {
name: 'my_metric',
type: 'my_metric_result',
inputTypes: ['datatable'],
help: 'Calculates a metric from a datatable column.',
args: {
column: {
types: ['string'],
help: 'Column name to aggregate.',
required: true,
},
operation: {
types: ['string'],
help: 'Aggregation operation.',
default: 'avg',
options: ['avg', 'sum', 'min', 'max', 'count'],
},
decimals: {
types: ['number'],
help: 'Decimal places in the result.',
default: 2,
},
},
fn(input: Datatable, args: MyMetricArguments): MyMetricResult {
const values = input.rows
.map((row) => row[args.column])
.filter((v): v is number => typeof v === 'number');
let value: number;
switch (args.operation) {
case 'sum':
value = values.reduce((a, b) => a + b, 0);
break;
case 'min':
value = Math.min(...values);
break;
case 'max':
value = Math.max(...values);
break;
case 'count':
value = values.length;
break;
case 'avg':
default:
value = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0;
break;
}
return {
type: 'my_metric_result',
value: Number(value.toFixed(args.decimals)),
label: `${args.operation}(${args.column})`,
operation: args.operation,
};
},
};
Register the function:
import { myMetricFunction } from '../common/expressions/my_metric';
public setup(core: CoreSetup, { expressions }: MyPluginSetupDeps) {
expressions.registerFunction(myMetricFunction);
}
Functions defined in common/ can be registered on both server and browser — the server execution is used for Canvas server-side rendering and reporting.
Expression Renderer
Renderers take a render config and mount a visualization into a DOM node:
import React from 'react';
import ReactDOM from 'react-dom';
import { ExpressionRenderDefinition } from '@kbn/expressions-plugin/common';
import { MyChartComponent } from './my_chart_component';
interface MyChartConfig {
type: 'my_chart_config';
data: Array<{ label: string; value: number }>;
colorScheme: string;
showLegend: boolean;
}
export const myChartRenderer: ExpressionRenderDefinition<MyChartConfig> = {
name: 'my_chart',
displayName: 'My Chart',
help: 'Renders a custom chart visualization.',
reuseDomNode: true,
render(domNode: HTMLElement, config: MyChartConfig, handlers) {
handlers.onDestroy(() => {
ReactDOM.unmountComponentAtNode(domNode);
});
ReactDOM.render(
<MyChartComponent
data={config.data}
colorScheme={config.colorScheme}
showLegend={config.showLegend}
onRenderComplete={() => handlers.done()}
/>,
domNode
);
},
};
Register:
public setup(core: CoreSetup, { expressions }: MyPluginSetupDeps) {
expressions.registerRenderer(myChartRenderer);
}
Render Function (Bridge Between Data and Renderer)
A render function converts processed data into a render config that a renderer can display:
import { ExpressionFunctionDefinition } from '@kbn/expressions-plugin/common';
interface MyChartRenderArgs {
colorScheme: string;
showLegend: boolean;
}
interface MyChartRenderConfig {
type: 'render';
as: 'my_chart';
value: {
type: 'my_chart_config';
data: Array<{ label: string; value: number }>;
colorScheme: string;
showLegend: boolean;
};
}
export const myChartRenderFunction: ExpressionFunctionDefinition<
'my_chart_render',
{ type: 'my_metric_result'; value: number; label: string } | any,
MyChartRenderArgs,
MyChartRenderConfig
> = {
name: 'my_chart_render',
type: 'render',
inputTypes: ['my_metric_result', 'datatable'],
help: 'Prepares data for the my_chart renderer.',
args: {
colorScheme: {
types: ['string'],
help: 'Color scheme name.',
default: 'default',
},
showLegend: {
types: ['boolean'],
help: 'Whether to show the legend.',
default: true,
},
},
fn(input, args): MyChartRenderConfig {
let data: Array<{ label: string; value: number }>;
if (input.type === 'my_metric_result') {
data = [{ label: input.label, value: input.value }];
} else {
data = input.rows.map((row: any) => ({
label: String(row.label || ''),
value: Number(row.value || 0),
}));
}
return {
type: 'render',
as: 'my_chart',
value: {
type: 'my_chart_config',
data,
colorScheme: args.colorScheme,
showLegend: args.showLegend,
},
};
},
};
Custom Expression Type
If your functions use a custom data shape, register it as an expression type:
import { ExpressionTypeDefinition } from '@kbn/expressions-plugin/common';
export const myMetricResultType: ExpressionTypeDefinition<
'my_metric_result',
{ type: 'my_metric_result'; value: number; label: string; operation: string }
> = {
name: 'my_metric_result',
validate: (value) => {
if (typeof value.value !== 'number') {
throw new Error('my_metric_result must have a numeric value');
}
},
from: {
number: (value: number) => ({
type: 'my_metric_result' as const,
value,
label: 'value',
operation: 'raw',
}),
},
to: {
number: (result) => result.value,
datatable: (result) => ({
type: 'datatable' as const,
columns: [
{ id: 'label', name: 'Label', meta: { type: 'string' } },
{ id: 'value', name: 'Value', meta: { type: 'number' } },
],
rows: [{ label: result.label, value: result.value }],
}),
},
};
Register:
expressions.registerType(myMetricResultType);
Datatable Reference
The datatable type is the standard interchange format — most functions consume and produce datatables:
interface Datatable {
type: 'datatable';
columns: DatatableColumn[];
rows: DatatableRow[];
}
interface DatatableColumn {
id: string;
name: string;
meta: {
type: 'number' | 'string' | 'boolean' | 'date' | 'null' | 'unknown';
field?: string;
index?: string;
params?: {
id?: string;
params?: Record<string, unknown>;
};
source?: string;
sourceParams?: Record<string, unknown>;
};
}
type DatatableRow = Record<string, unknown>;
Common operations on datatables:
const filtered = {
...input,
rows: input.rows.filter((row) => row.status === 'active'),
};
const withColumn = {
...input,
columns: [...input.columns, { id: 'new_col', name: 'New Column', meta: { type: 'string' } }],
rows: input.rows.map((row) => ({
...row,
new_col: computeValue(row),
})),
};
const sorted = {
...input,
rows: [...input.rows].sort((a, b) =>
(a[sortField] as number) - (b[sortField] as number)
),
};
Using Expressions in Canvas
Once registered, your functions and renderers can be used in Canvas expressions:
essql "SELECT category, AVG(response_time) as avg_time FROM logs GROUP BY category"
| my_metric column="avg_time" operation="max"
| my_chart_render colorScheme="warm" showLegend=true
Or via the expression editor in Canvas workpads.
Executing Expressions Programmatically
Run expressions from your plugin code:
const { expressions } = useKibana().services;
const result = await expressions
.execute('my_metric', null, {
column: 'response_time',
operation: 'avg',
})
.getData();
Or using the expression loader for rendering:
import { ReactExpressionRenderer } from '@kbn/expressions-plugin/public';
<ReactExpressionRenderer
expression='essql "SELECT * FROM logs" | my_metric column="value" | my_chart_render'
onData$={(data) => console.log('Expression result:', data)}
onRenderError={(error) => console.error('Render error:', error)}
/>
Expressions Best Practices
- Functions must be pure — same input + args = same output, no side effects
- Place functions in
common/ — they can then run on both server (reporting, Canvas server rendering) and browser
- Renderers are browser-only — they touch the DOM
- Always call
handlers.done() in renderers — Canvas and Dashboard wait for this signal to know rendering is complete
- Use
reuseDomNode: true on renderers that update frequently — avoids expensive DOM recreation
- Register cleanup via
handlers.onDestroy() — unmount React, cancel animations, remove listeners
- Use the
datatable type for data interchange — it's the standard and works with all built-in functions
- Keep functions focused — one function = one transformation, chain them together
- Handle
null input — some pipeline positions may produce null
- Test functions independently — they're pure functions, test with unit tests and fixture data
State Management
Kibana has multiple state management layers that plugins can use. The right choice depends on where the state lives, how long it persists, and whether it needs to be shareable via URL.
State lifecycle in Kibana:
- URL state — survives page refresh, shareable via link (time range, filters, query)
- Session state — survives in-app navigation, lost on page refresh (expanded rows, scroll position)
- App state — plugin-specific state in the URL hash (selected tab, view mode)
- Global state — shared across plugins in the URL (time range, refresh interval, filters)
- Saved object state — persisted to Elasticsearch, survives everything (dashboard layouts, saved searches)
URL State Sync (kbn-state-sync)
Kibana's @kbn/kibana-utils-plugin provides utilities to sync application state with the URL. This is how dashboards preserve filters, time range, and panel layout in the URL.
import {
createStateContainer,
syncState,
createKbnUrlStateStorage,
IKbnUrlStateStorage,
} from '@kbn/kibana-utils-plugin/public';
import { CoreStart } from '@kbn/core/public';
import { History } from 'history';
interface MyAppState {
selectedTab: string;
viewMode: 'table' | 'grid' | 'detail';
sortField: string;
sortDirection: 'asc' | 'desc';
pageIndex: number;
}
const defaultState: MyAppState = {
selectedTab: 'overview',
viewMode: 'table',
sortField: 'updated_at',
sortDirection: 'desc',
pageIndex: 0,
};
export function setupUrlStateSync(history: History) {
const stateContainer = createStateContainer<MyAppState>(defaultState);
const kbnUrlStateStorage = createKbnUrlStateStorage({
useHash: false,
history,
});
const { start, stop } = syncState({
storageKey: '_a',
stateContainer: {
get: () => stateContainer.get(),
set: (state) => stateContainer.set(state),
state$: stateContainer.state$,
},
stateStorage: kbnUrlStateStorage,
});
start();
if (!kbnUrlStateStorage.get('_a')) {
kbnUrlStateStorage.set('_a', defaultState);
}
return {
stateContainer,
stop,
};
}
Use in a React component:
import React, { useEffect, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { setupUrlStateSync } from './state/use_url_state_sync';
export const MyApp: React.FC = () => {
const history = useHistory();
const [appState, setAppState] = useState(null);
useEffect(() => {
const { stateContainer, stop } = setupUrlStateSync(history);
const sub = stateContainer.state$.subscribe((state) => {
setAppState({ ...state });
});
setAppState(stateContainer.get());
return () => {
sub.unsubscribe();
stop();
};
}, [history]);
if (!appState) return null;
const handleTabChange = (tab: string) => {
stateContainer.set({ ...stateContainer.get(), selectedTab: tab });
};
return <MyContent state={appState} onTabChange={handleTabChange} />;
};
Global State (Time Range, Filters, Query)
Dashboard-level state (time range, filters, refresh interval) is managed by the data plugin. Access it through the query service:
const { data } = useKibana().services;
const timeRange = data.query.timefilter.timefilter.getTime();
const filters = data.query.filterManager.getFilters();
const query = data.query.queryString.getQuery();
const refreshInterval = data.query.timefilter.timefilter.getRefreshInterval();
useEffect(() => {
const timeSub = data.query.timefilter.timefilter.getTimeUpdate$().subscribe(() => {
const newTimeRange = data.query.timefilter.timefilter.getTime();
fetchData(newTimeRange);
});
const filterSub = data.query.filterManager.getUpdates$().subscribe(() => {
const newFilters = data.query.filterManager.getFilters();
fetchData(undefined, newFilters);
});
return () => {
timeSub.unsubscribe();
filterSub.unsubscribe();
};
}, [data.query]);
data.query.timefilter.timefilter.setTime({ from: 'now-1h', to: 'now' });
data.query.filterManager.addFilters([{
meta: { alias: null, disabled: false, negate: false },
query: { match_phrase: { status: 'error' } },
}]);
data.query.queryString.setQuery({ language: 'kuery', query: 'host.name: "web-01"' });
State Containers (createStateContainer)
State containers are lightweight observable stores. Use them for plugin-internal state that doesn't need URL sync:
import { createStateContainer } from '@kbn/kibana-utils-plugin/public';
interface PluginState {
isLoading: boolean;
selectedItems: string[];
expandedRowId: string | null;
sidebarOpen: boolean;
}
const initialState: PluginState = {
isLoading: false,
selectedItems: [],
expandedRowId: null,
sidebarOpen: true,
};
const container = createStateContainer(
initialState,
{
setLoading: (state) => (isLoading: boolean) => ({ ...state, isLoading }),
toggleSidebar: (state) => () => ({ ...state, sidebarOpen: !state.sidebarOpen }),
selectItem: (state) => (id: string) => ({
...state,
selectedItems: state.selectedItems.includes(id)
? state.selectedItems.filter((i) => i !== id)
: [...state.selectedItems, id],
}),
expandRow: (state) => (id: string | null) => ({ ...state, expandedRowId: id }),
clearSelection: (state) => () => ({ ...state, selectedItems: [] }),
}
);
container.transitions.setLoading(true);
container.transitions.selectItem('item-1');
container.transitions.toggleSidebar();
container.state$.subscribe((state) => {
console.log('State changed:', state);
});
const current = container.get();
React Hooks for State
Custom hook for subscribing to a state container:
import { useState, useEffect, useRef } from 'react';
import { Observable } from 'rxjs';
import { distinctUntilChanged, map } from 'rxjs/operators';
export function useObservable<T>(observable$: Observable<T>, initialValue: T): T {
const [value, setValue] = useState(initialValue);
useEffect(() => {
const sub = observable$.subscribe(setValue);
return () => sub.unsubscribe();
}, [observable$]);
return value;
}
export function useStateSlice<T, R>(
observable$: Observable<T>,
selector: (state: T) => R,
initialValue: R
): R {
const [value, setValue] = useState(initialValue);
useEffect(() => {
const sub = observable$.pipe(
map(selector),
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b))
).subscribe(setValue);
return () => sub.unsubscribe();
}, [observable$, selector]);
return value;
}
Usage:
const MyComponent: React.FC = () => {
const isLoading = useStateSlice(
container.state$,
(state) => state.isLoading,
false
);
const selectedItems = useStateSlice(
container.state$,
(state) => state.selectedItems,
[]
);
};
Session Storage
For state that should survive in-app navigation but not page refresh:
import { createSessionStorageStateStorage } from '@kbn/kibana-utils-plugin/public';
const sessionStorage = createSessionStorageStateStorage();
sessionStorage.set('myPlugin:expandedRows', expandedRowIds);
const restored = sessionStorage.get<string[]>('myPlugin:expandedRows');
Use session storage for: expanded accordion sections, scroll positions, selected tab indices, temporary UI preferences.
State Management Best Practices
- URL state for shareable context — if a user should be able to share a link that reproduces their view, put it in the URL
- State containers for component state — use
createStateContainer instead of lifting state through many React component levels
- Subscribe to slices, not the whole state — use
distinctUntilChanged with selectors to avoid unnecessary re-renders
- Clean up subscriptions — every
.subscribe() needs an .unsubscribe() in the cleanup function
- Use
_a for app state, _g for global state — this is Kibana's convention for URL state keys
- Don't duplicate global state — time range, filters, and query are managed by the
data plugin; subscribe to them, don't re-implement
- Use transitions for predictable mutations — named transitions make state changes traceable and testable
- Session storage for ephemeral UI state — scroll position, expanded rows, sidebar open/closed
- Saved objects for persistent state — anything the user explicitly "saves" should be a saved object
- Test state transitions independently — state containers are plain objects with functions, easily unit-testable
Logging & Monitoring
Kibana provides structured logging, usage telemetry, performance tracking, and event logging for plugins. Proper logging is critical for debugging production issues and understanding plugin usage.
Server-Side Logging
Kibana uses a structured logging system based on Log4j-style levels and hierarchical logger names.
import { PluginInitializerContext, Logger } from '@kbn/core/server';
export class MyPlugin {
private readonly logger: Logger;
constructor(private readonly initializerContext: PluginInitializerContext) {
this.logger = initializerContext.logger.get();
}
public setup(core: CoreSetup) {
this.logger.info('Plugin setup started');
const routeLogger = this.logger.get('routes');
const serviceLogger = this.logger.get('services');
const syncLogger = this.logger.get('sync');
registerRoutes(core.http.createRouter(), routeLogger);
this.syncService = new SyncService(syncLogger);
}
}
Log levels:
this.logger.trace('Detailed tracing info — most verbose');
this.logger.debug('Debug info — useful during development');
this.logger.info('Normal operational messages');
this.logger.warn('Something unexpected but not fatal');
this.logger.error('Something failed');
this.logger.fatal('Unrecoverable error — plugin cannot function');
Structured Logging with Meta
Always include structured metadata for searchable logs:
this.logger.info('User created', {
userId: user.id,
username: user.username,
createdBy: request.auth?.username,
});
this.logger.error('Failed to sync data', {
index: targetIndex,
documentCount: docs.length,
error: error.message,
stack: error.stack,
});
this.logger.warn('Rate limit approaching', {
currentRate: requestCount,
limit: MAX_REQUESTS_PER_MINUTE,
endpoint: '/api/my_plugin/sync',
});
this.logger.info(`User ${user.username} created by ${request.auth?.username}`);
this.logger.error(`Failed: ${error}`);
Logging in Route Handlers
Pass the logger to routes and use it consistently:
import { IRouter, Logger } from '@kbn/core/server';
export function registerItemRoutes(router: IRouter, logger: Logger) {
router.post(
{
path: '/api/my_plugin/items',
validate: { },
},
async (context, request, response) => {
const startTime = Date.now();
try {
logger.debug('Creating item', { body: request.body });
const esClient = (await context.core).elasticsearch.client.asCurrentUser;
const result = await esClient.index({ });
const duration = Date.now() - startTime;
logger.info('Item created', {
itemId: result._id,
duration: `${duration}ms`,
});
return response.ok({ body: { id: result._id } });
} catch (error: any) {
const duration = Date.now() - startTime;
logger.error('Failed to create item', {
error: error.message,
statusCode: error?.statusCode,
duration: `${duration}ms`,
});
return response.customError({
statusCode: error?.statusCode || 500,
body: { message: 'Failed to create item' },
});
}
}
);
}
Configuring Log Levels (kibana.yml)
logging:
loggers:
- name: plugins.myPlugin
level: debug
appenders:
- default
- name: plugins.myPlugin.routes
level: trace
- name: plugins.myPlugin.sync
level: warn
appenders:
my-plugin-file:
type: file
fileName: /var/log/kibana/my-plugin.log
layout:
type: json
loggers:
- name: plugins.myPlugin
level: info
appenders:
- my-plugin-file
Usage Telemetry (Usage Collector)
Register a usage collector to report plugin usage statistics to Elastic's telemetry system. This data helps understand how plugins are used (opt-in, anonymized).
import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server';
import { SavedObjectsClientContract } from '@kbn/core/server';
interface MyPluginUsage {
total_configs: number;
active_configs: number;
total_syncs_last_24h: number;
avg_items_per_config: number;
features_used: {
advanced_mode: number;
scheduled_sync: number;
custom_mappings: number;
};
}
export function registerUsageCollector(
usageCollection: UsageCollectionSetup,
getSavedObjectsClient: () => SavedObjectsClientContract | undefined
) {
const collector = usageCollection.makeUsageCollector<MyPluginUsage>({
type: 'my_plugin',
isReady: () => !!getSavedObjectsClient(),
schema: {
total_configs: { type: 'long' },
active_configs: { type: 'long' },
total_syncs_last_24h: { type: 'long' },
avg_items_per_config: { type: 'float' },
features_used: {
advanced_mode: { type: 'long' },
scheduled_sync: { type: 'long' },
custom_mappings: { type: 'long' },
},
},
async fetch() {
const client = getSavedObjectsClient();
if (!client) {
return {
total_configs: 0,
active_configs: 0,
total_syncs_last_24h: 0,
avg_items_per_config: 0,
features_used: { advanced_mode: 0, scheduled_sync: 0, custom_mappings: 0 },
};
}
const configs = await client.find({ type: 'my-plugin-config', perPage: 10000 });
const activeConfigs = configs.saved_objects.filter((c) => c.attributes.enabled);
return {
total_configs: configs.total,
active_configs: activeConfigs.length,
total_syncs_last_24h: await countRecentSyncs(client),
avg_items_per_config: calculateAverage(configs.saved_objects),
features_used: countFeatureUsage(configs.saved_objects),
};
},
});
usageCollection.registerCollector(collector);
}
Register in setup:
public setup(core: CoreSetup, { usageCollection }: MyPluginSetupDeps) {
if (usageCollection) {
registerUsageCollector(usageCollection, () => this.savedObjectsClient);
}
}
Add usageCollection to optionalPlugins in kibana.jsonc.
Performance Monitoring
Track operation timing for performance analysis:
import { Logger } from '@kbn/core/server';
export function withTiming<T>(
logger: Logger,
operationName: string,
fn: () => Promise<T>,
meta?: Record<string, unknown>
): Promise<T> {
const start = performance.now();
return fn()
.then((result) => {
const duration = performance.now() - start;
logger.debug(`${operationName} completed`, {
...meta,
duration_ms: Math.round(duration * 100) / 100,
});
if (duration > 5000) {
logger.warn(`${operationName} slow execution`, {
...meta,
duration_ms: Math.round(duration),
});
}
return result;
})
.catch((error) => {
const duration = performance.now() - start;
logger.error(`${operationName} failed`, {
...meta,
duration_ms: Math.round(duration),
error: error.message,
});
throw error;
});
}
const result = await withTiming(
logger,
'elasticsearch_search',
() => esClient.search({ index: 'my-index', body: query }),
{ index: 'my-index', queryType: 'filtered_search' }
);
Event Log (for auditable actions)
If your plugin performs actions that need an audit trail, use the Event Log:
import { IEventLogService } from '@kbn/event-log-plugin/server';
public setup(core: CoreSetup, { eventLog }: { eventLog: IEventLogService }) {
eventLog.registerProviderActions('myPlugin', ['execute', 'create', 'delete', 'sync']);
}
public start(core: CoreStart, { eventLog }: { eventLog: IEventLogService }) {
const eventLogger = eventLog.getLogger({ event: { provider: 'myPlugin' } });
eventLogger.logEvent({
event: {
action: 'execute',
outcome: 'success',
},
message: 'Sync job completed',
kibana: {
saved_objects: [
{ rel: 'primary', type: 'my-plugin-config', id: configId },
],
},
});
}
Add eventLog to optionalPlugins.
Client-Side Logging
On the public side, avoid console.log in production. Use the Kibana fatal error handler for unrecoverable errors and notifications for user-facing messages:
if (process.env.NODE_ENV !== 'production') {
console.debug('[myPlugin] Debug info:', data);
}
core.fatalErrors.add(new Error('My plugin failed to initialize'));
core.notifications.toasts.addDanger({
title: 'Sync failed',
text: `Unable to sync data: ${error.message}`,
});
Logging Best Practices
- Use structured meta — pass objects, not interpolated strings, for searchability
- Create child loggers — use
this.logger.get('subsystem') to create hierarchical loggers
- Log at the right level —
debug for development, info for operations, warn for recoverable issues, error for failures
- Include timing — log durations for ES queries, external API calls, and long-running operations
- Never log sensitive data — don't log passwords, tokens, PII, or full request bodies in production
- Never use
console.log on the server — use the Kibana logger exclusively
- Log on boundaries — log when entering and exiting important operations (route handlers, service methods, background tasks)
- Use telemetry for analytics — don't parse logs for usage stats, use the Usage Collector
- Configure per-plugin levels — use
kibana.yml to turn up logging for debugging without drowning in noise from other plugins