Back to Kibana

@kbn/change-history

x-pack/platform/packages/shared/kbn-change-history/README.md

9.4.014.7 KB
Original Source

@kbn/change-history

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.

Unsupported functionality

Kibana objects that use a dot . in their JSON structure.

json
{
  "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.

Overview

Single shared data stream, one client per (module, dataset):

  • All clients write to one data stream: .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?):

  • Returns change documents for the given object 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).

Event IDs

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.

API

Client

  • 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.

    • Required: action, username, spaceId.
    • Optional:
      • userProfileId user profile from auth realm,
      • correlationId to groups bulk events in a common transaction when set,
      • change 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?)

    • Returns a promise with { total, items }.
    • spaceId — The Kibana space ID where the object exists (used to scope the search).
    • Results are scoped by spaceId, the client’s module and dataset, and filtered by object.type and object.id.
    • Optional opts: GetChangeHistoryOptions with additionalFilters (array of ES query clauses), pagination options sort, from, size (default 100).
    • Results are sorted by object.sequence (if available), then @timestamp, and event.id as the tie-breaker.

Special functionality: Diffing behavior

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:

ts
// 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.


Elasticsearch mapping schema

The data stream uses dynamic: false and the following index mapping (defined by changeHistoryMappings.v1 in the package):

FieldTypeDescription
@timestampdateISO8601 timestamp of the change.
ecsobjectECS version.
ecs.versionkeywordECS schema version (e.g. 9.3.0).
userobjectUser who performed the change.
user.idkeywordOptional user profile ID from auth realm. Refer to ES User Profiles.
user.namekeywordCurrent login name. (Required)
eventobjectEvent metadata.
event.idkeywordUnique identifier for the event.
event.modulekeywordKibana module / domain (e.g. security). Used to scope writes and queries.
event.datasetkeywordFeature dataset (e.g. alerting-rules). Used to scope writes and queries.
event.actionkeywordAction that triggered the change (e.g. rule_create, rule_update, rule_delete). See additional examples.
event.typekeywordECS categorization: creation, change, deletion.
event.reasontextUser-specified reason for the change. (Optional)
event.createddateISO8601 timestamp of the event creation time. Generated by library.
transactionobjectTransaction for bulk operations. (Optional)
transaction.idkeywordID shared between events that take place in a bulk transaction. (Optional)
objectobjectThe tracked object.
object.idkeywordUnique id of the target object in Kibana.
object.typekeywordType of the target object (e.g. alert). Allows tracking multiple types in the same change history stream.
object.indexkeywordES backing index where this object was stored. (Optional)
object.hashkeywordSHA256 of the object snapshot to identify the payload.
object.sequenceintegerVersion/sequence number for ordering. (Optional)
object.diffobjectDiff metadata when before is provided. (Optional)
object.diff.typekeywordDiff calculation type (e.g. default).
object.diff.fieldskeywordList of field names that changed (full paths). (Optional)
object.diff.before(unmapped)Previous values for changed fields. (Optional)
object.fieldsobjectField data for the stored snapshot.
object.fields.hashedkeywordList of fields (full paths) whose values were replaced with hashes (SHA-256).
object.snapshot(unmapped)Full snapshot after the change.
tagskeywordOptional list of tags for the event.
metadataflattenedOptional structured metadata; does not form part of the diff or ECS schema.
kibanaobjectKibana context.
kibana.space_idskeywordID of the space that the event belongs to.
serviceobjectService context.
service.typekeywordService type (e.g. kibana).
service.versionkeywordVersion 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.

Dependencies

See tsconfig.json for internal kibana references.


Usage examples

Basic usage (no frills)

ts
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'])}`
);

Object update with diff

ts
// 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}`
);

Bulk changes with correlation ID

Multiple changes in one request share a transaction id so they can be queried together. Pass a correlationId:

ts
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',
});

Adding tags, reason, and metadata

Use data to set event fields (e.g. reason), tags, and metadata on the stored document:

ts
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' },
    },
  }
);

Ignored and hashed fields

Dealing with domain-specific data that should be ignored in the diff or hashed in the stored snapshot (Sensitive data or binary blobs).

ts
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,
  },
});

Logging a deletion

Store the last known state as the snapshot and mark the event as a deletion:

ts
await client.log(change, {
  action: 'rule_delete',
  username,
  spaceId,
  data: { event: { type: 'deletion', reason: 'User requested deletion' } },
});

Querying with filters and pagination

ts
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']}`
);

Testing

Run the following from the Kibana repository root.

Unit tests (Jest, no Elasticsearch):

bash
yarn test:jest --config=x-pack/platform/packages/shared/kbn-change-history/jest.config.js

Integration tests (Jest with a real Elasticsearch node; slower):

bash
yarn test:jest_integration --config=x-pack/platform/packages/shared/kbn-change-history/jest.integration.config.js