x-pack/platform/packages/shared/kbn-change-history/README.md
Generic change-history storage and query for Kibana.
Persists point-in-time snapshots of object changes to Elasticsearch data streams, with optional diff metadata (what changed between consecutive versions).
Solution-agnostic: use it from any plugin or module that needs audit-style history.
. in their JSON structure.{
"user": { "first.name": "bob"}
}
The change history package does not currently support JSON structures that use a dot . for property names, as it relies on "flattening" JSON into dot notation for JSON paths.
Single shared data stream, one client per (module, dataset):
.kibana-change-history. Each client is bound to a module and dataset, analogs of the "business domain" and "feature".Log changes with log(change, opts) / logBulk(changes, opts):
Each change has the following (see Usage examples below):
timestamp (Optional @timestamp when the change took place - will be autogenerated if not provided)objectType (Used for querying - allows multiple object types in the same change history stream),objectId (Used for querying - uniquely identifies a kibana object),index (Optional ES backing index),sequence (Optional numeric version for ordering changes).before (Optional pre-change snapshot).after (post-change snapshot)There is also an opts object that contains the action that took place, and relevant username and other contextual information.
When change.before is provided, a diff is computed using a default calculation.
Query history with getHistory(spaceId, objectType, objectId, opts?):
type and id in the specified Kibana space, sorted by sequence (if available), then @timestamp, then event.id as a tie-breaker. Supports pagination and custom sort/filters via opts.All persisted documents follow the same schema (see below).
Each persisted document gets a unique event.id assigned by the package (UUID v7).
UUID v7 values are monotonically increasing within the same millisecond. That matters when two change history events are written back-to-back with the same timestamp (for example rule_install and rule_enable when user presses a button to "Install and enable" a detectino rule): as getHistory() sorts by sequence, then @timestamp, then event.id as a tie-breaker, so ordering stays deterministic.
new ChangeHistoryClient({ module, dataset, logger, kibanaVersion })
Constructs a client for the given module, dataset, and kibanaVersion. All clients write to the shared data stream .kibana-change-history; each client’s writes are scoped by module and dataset.
isInitialized() — Returns true if the client has been initialized (e.g. after initialize() has been called).
initialize(elasticsearchClient)
Creates/ensures the data stream and stores the internal client. Called once during plugin start() phase and before log / logBulk / getHistory.
log(change, opts)
Writes one change document with given opts context (action, username, etc) in LogChangeHistoryOptions.
logBulk(changes, opts)
Same as log but for multiple changes in one request (grouped by correlationId if provided).
LogChangeHistoryOptions — Options for logging a change.
action, username, spaceId.userProfileId user profile from auth realm,correlationId to groups bulk events in a common transaction when set,data overrides (partial event, tags, and metadata to merge into the document),fieldsToIgnore a nested key/value map of fields to exclude from the diff calculation,fieldsToHash a nested key/value map of fields to hash in the stored snapshot (PII, secrets, large base64 blobs, etc. — only string values are hashed),refresh an optional indicator to force ES shard refresh after changes (affects performance).getHistory(spaceId, objectType, objectId, opts?)
{ total, items }.spaceId — The Kibana space ID where the object exists (used to scope the search).spaceId, the client’s module and dataset, and filtered by object.type and object.id.opts: GetChangeHistoryOptions with additionalFilters (array of ES query clauses), pagination options sort, from, size (default 100).object.sequence (if available), then @timestamp, and event.id as the tie-breaker.When logging change history items, you may pass an additional payload before (pre-change snapshot). This triggers a diff calculation between before and after objects that is stored under object.diff in the mapping schema:
// After an object update (diff is computed from before → after):
const change: ObjectChange = {
objectType: 'alerting-rule',
objectId: ruleId,
before: previousSnapshot, // <-- optional; if set, diff is computed
after: ruleSnapshot,
};
await client.log(change, {
action: 'rule_update',
username,
spaceId,
});
⚠️ Important: If the diff calculation throws an error, object.diff will also be missing from the stored document.
The data stream uses dynamic: false and the following index mapping (defined by changeHistoryMappings.v1 in the package):
| Field | Type | Description |
|---|---|---|
@timestamp | date | ISO8601 timestamp of the change. |
ecs | object | ECS version. |
ecs.version | keyword | ECS schema version (e.g. 9.3.0). |
user | object | User who performed the change. |
user.id | keyword | Optional user profile ID from auth realm. Refer to ES User Profiles. |
user.name | keyword | Current login name. (Required) |
event | object | Event metadata. |
event.id | keyword | Unique identifier for the event. |
event.module | keyword | Kibana module / domain (e.g. security). Used to scope writes and queries. |
event.dataset | keyword | Feature dataset (e.g. alerting-rules). Used to scope writes and queries. |
event.action | keyword | Action that triggered the change (e.g. rule_create, rule_update, rule_delete). See additional examples. |
event.type | keyword | ECS categorization: creation, change, deletion. |
event.reason | text | User-specified reason for the change. (Optional) |
event.created | date | ISO8601 timestamp of the event creation time. Generated by library. |
transaction | object | Transaction for bulk operations. (Optional) |
transaction.id | keyword | ID shared between events that take place in a bulk transaction. (Optional) |
object | object | The tracked object. |
object.id | keyword | Unique id of the target object in Kibana. |
object.type | keyword | Type of the target object (e.g. alert). Allows tracking multiple types in the same change history stream. |
object.index | keyword | ES backing index where this object was stored. (Optional) |
object.hash | keyword | SHA256 of the object snapshot to identify the payload. |
object.sequence | integer | Version/sequence number for ordering. (Optional) |
object.diff | object | Diff metadata when before is provided. (Optional) |
object.diff.type | keyword | Diff calculation type (e.g. default). |
object.diff.fields | keyword | List of field names that changed (full paths). (Optional) |
object.diff.before | (unmapped) | Previous values for changed fields. (Optional) |
object.fields | object | Field data for the stored snapshot. |
object.fields.hashed | keyword | List of fields (full paths) whose values were replaced with hashes (SHA-256). |
object.snapshot | (unmapped) | Full snapshot after the change. |
tags | keyword | Optional list of tags for the event. |
metadata | flattened | Optional structured metadata; does not form part of the diff or ECS schema. |
kibana | object | Kibana context. |
kibana.space_ids | keyword | ID of the space that the event belongs to. |
service | object | Service context. |
service.type | keyword | Service type (e.g. kibana). |
service.version | keyword | Version of Kibana. |
Variable-shape fields object.snapshot and object.diff.before are stored but unmapped; metadata uses the flattened type so arbitrary keys can be stored and indexed without dynamic mapping.
See tsconfig.json for internal kibana references.
import { ChangeHistoryClient } from '@kbn/change-history';
import type { ObjectChange, LogChangeHistoryOptions } from '@kbn/change-history';
// During plugin `setup()` phase
const client = new ChangeHistoryClient({
module: 'security',
dataset: 'detections',
logger,
kibanaVersion: '9.4.0',
});
// During plugin `start()` phase
await client.initialize(elasticsearchClient);
const spaceId = 'default';
// When user makes a change
const change: ObjectChange = {
objectType: 'alerting-rule',
objectId: ruleId,
after: ruleSnapshot, // <-- Version after changes, the raw object we use for reverting
};
await client.log(change, {
action: 'rule_create',
username,
spaceId,
});
// When reading history for an object
const { total, items } = await client.getHistory(spaceId, 'alerting-rule', ruleId);
console.log(
`We have ${total} items, latest change at: \n${JSON.stringify(items[0]?.['@timestamp'])}`
);
// After an object update (diff is computed from before → after):
const change: ObjectChange = {
objectType: 'alerting-rule',
objectId: ruleId,
before: previousSnapshot, // <-- optional; if set, diff is computed
after: ruleSnapshot,
};
await client.log(change, {
action: 'rule_update',
username,
spaceId,
});
// Read history for an object
const { total, items } = await client.getHistory(spaceId, 'alerting-rule', ruleId);
const { object } = items[0] ?? {};
console.log(
`We have just updated the following fields: \n${object.diff?.fields}`
);
Multiple changes in one request share a transaction id so they can be queried together. Pass a correlationId:
const changes: ObjectChange[] = [
{ objectType: 'alerting-rule', objectId: id1, before: before1, after: after1 },
{ objectType: 'alerting-rule', objectId: id2, before: before2, after: after2 },
];
await client.logBulk(changes, {
action: 'rule_bulk_update',
username,
spaceId,
correlationId: 'my-bulk-operation-123',
});
Use data to set event fields (e.g. reason), tags, and metadata on the stored document:
await client.log(
{
objectType: 'alerting-rule',
objectId: ruleId,
before: previousSnapshot,
after: newSnapshot,
},
{
action: 'rule_update',
username,
spaceId,
data: {
event: { reason: 'Threshold adjusted by user' },
tags: ['new-rules-ui', 'manual-edit'],
metadata: { tab: 'settings' },
},
}
);
Dealing with domain-specific data that should be ignored in the diff or hashed in the stored snapshot (Sensitive data or binary blobs).
await client.log(change, {
action: 'rule_update',
username,
spaceId,
// Fields that should not participate in the diff (e.g. volatile or system fields)
fieldsToIgnore: {
updatedAt: true,
monitoringData: true,
params: { isUpdated: true },
},
// Fields containing sensitive data that should be masked
fieldsToHash: {
user: { email: true },
apiKey: true,
},
});
Store the last known state as the snapshot and mark the event as a deletion:
await client.log(change, {
action: 'rule_delete',
username,
spaceId,
data: { event: { type: 'deletion', reason: 'User requested deletion' } },
});
const { total, items } = await client.getHistory(spaceId, 'alerting-rule', ruleId, {
additionalFilters: [{ range: { '@timestamp': { lt: '2026-01-01' } } }],
size: 50,
from: 0,
});
console.log(
`Last update in 2025 was at ${items[0]?.['@timestamp']}`
);
Run the following from the Kibana repository root.
Unit tests (Jest, no Elasticsearch):
yarn test:jest --config=x-pack/platform/packages/shared/kbn-change-history/jest.config.js
Integration tests (Jest with a real Elasticsearch node; slower):
yarn test:jest_integration --config=x-pack/platform/packages/shared/kbn-change-history/jest.integration.config.js