rolling file appender: implement retention policy (#182346)

## Summary

Fix https://github.com/elastic/kibana/issues/165191

Support more complex retention policies via the introduction of the
`retention` configuration section of the `rolling-file` appender.

3 different retention policies are supported:
- File count based (`maxFiles`)
- Total file size based (`maxAccumulatedFileSize`)
- Last modification date based (`removeOlderThan`)

Backward-compatibility is assured by using the `strategy.max` (the only
way to define the only retention before this PR) as a default for
`retention.maxFiles`

## Examples

#### maxFiles

```yaml
logging:
  appenders:
    rolling-file:
      type: rolling-file
      fileName: /var/logs/kibana.log
      layout:
        type: pattern
      policy:
        type: time-interval
        interval: 1d
        modulate: true
      retention:
        maxFiles: 5
```

#### maxAccumulatedFileSize

```yaml
logging:
  appenders:
    rolling-file:
      type: rolling-file
      fileName: /var/logs/kibana.log
      layout:
        type: pattern
      policy:
        type: time-interval
        interval: 1d
        modulate: true
      retention:
        maxAccumulatedFileSize: "5GB"
```

#### removeOlderThan

```yaml
logging:
  appenders:
    rolling-file:
      type: rolling-file
      fileName: /var/logs/kibana.log
      layout:
        type: pattern
      policy:
        type: time-interval
        interval: 1d
        modulate: true
      retention:
        removeOlderThan: "30d"
```

## Release note

Kibana's `rolling-file` appender now supports more advanced retention
policies. Please refer to the Kibana logging documentation for more
details
This commit is contained in:
Pierre Gayvallet 2024-05-07 17:04:56 +02:00 committed by GitHub
parent 3b4aa1ebbc
commit 6ca7cbcffc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 862 additions and 109 deletions

View file

@ -12,13 +12,16 @@ import type { RollingFileContext } from './rolling_file_context';
import type { RollingFileManager } from './rolling_file_manager';
import type { TriggeringPolicy } from './policies/policy';
import type { RollingStrategy } from './strategies/strategy';
import type { RetentionPolicy } from './retention/retention_policy';
const createContextMock = (filePath: string) => {
const createContextMock = (filePath: string = 'kibana.log') => {
const mock: jest.Mocked<RollingFileContext> = {
currentFileSize: 0,
currentFileTime: 0,
filePath,
refreshFileInfo: jest.fn(),
getOrderedRolledFiles: jest.fn(),
setOrderedRolledFileFn: jest.fn(),
};
return mock;
};
@ -52,10 +55,18 @@ const createFileManagerMock = () => {
return mock;
};
const createRetentionPolicyMock = () => {
const mock: jest.Mocked<RetentionPolicy> = {
apply: jest.fn(),
};
return mock;
};
export const rollingFileAppenderMocks = {
createContext: createContextMock,
createStrategy: createStrategyMock,
createPolicy: createPolicyMock,
createLayout: createLayoutMock,
createFileManager: createFileManagerMock,
createRetentionPolicy: createRetentionPolicyMock,
};

View file

@ -0,0 +1,34 @@
/*
* 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 { RollingStrategyConfig } from '@kbn/core-logging-server';
import { mergeRetentionPolicyConfig } from './create_retention_policy';
describe('mergeRetentionPolicyConfig', () => {
const createRollingStrategyConfig = (max: number): RollingStrategyConfig => {
return {
type: 'numeric',
pattern: '-%i',
max,
};
};
it('uses the value from the retention strategy config if defined', () => {
const merged = mergeRetentionPolicyConfig({ maxFiles: 42 }, createRollingStrategyConfig(10));
expect(merged).toEqual({
maxFiles: 42,
});
});
it('uses the value from the rolling strategy config not defined on the retention config', () => {
const merged = mergeRetentionPolicyConfig({}, createRollingStrategyConfig(10));
expect(merged).toEqual({
maxFiles: 10,
});
});
});

View file

@ -0,0 +1,28 @@
/*
* 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 { RetentionPolicyConfig, RollingStrategyConfig } from '@kbn/core-logging-server';
import type { RollingFileContext } from '../rolling_file_context';
import { GenericRetentionPolicy, type RetentionPolicy } from './retention_policy';
export const createRetentionPolicy = (
config: RetentionPolicyConfig,
context: RollingFileContext
): RetentionPolicy => {
return new GenericRetentionPolicy(config, context);
};
export const mergeRetentionPolicyConfig = (
config: RetentionPolicyConfig | undefined,
strategyConfig: RollingStrategyConfig
): RetentionPolicyConfig => {
return {
...config,
maxFiles: config?.maxFiles || strategyConfig.max,
};
};

View file

@ -0,0 +1,27 @@
/*
* 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 { unlink, stat } from 'fs/promises';
export const deleteFiles = async ({ filesToDelete }: { filesToDelete: string[] }) => {
await Promise.all(filesToDelete.map((fileToDelete) => unlink(fileToDelete)));
};
export type GetFileInfoResult = { exist: false } | { exist: true; size: number; mtime: Date };
export const getFileInfo = async (absFilePath: string): Promise<GetFileInfoResult> => {
try {
const { size, mtime } = await stat(absFilePath);
return { exist: true, size, mtime };
} catch (e) {
if (e.code === 'ENOENT') {
return { exist: false };
}
throw e;
}
};

View file

@ -0,0 +1,10 @@
/*
* 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.
*/
export { retentionPolicyConfigSchema, type RetentionPolicy } from './retention_policy';
export { createRetentionPolicy, mergeRetentionPolicyConfig } from './create_retention_policy';

View file

@ -0,0 +1,32 @@
/*
* 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 { listFilesOlderThan, listFilesExceedingSize } from './utils';
import type { deleteFiles } from './fs';
export const listFilesExceedingSizeMock: jest.MockedFn<typeof listFilesExceedingSize> = jest.fn();
export const listFilesOlderThanMock: jest.MockedFn<typeof listFilesOlderThan> = jest.fn();
jest.doMock('./utils', () => {
const actual = jest.requireActual('./utils');
return {
...actual,
listFilesExceedingSize: listFilesExceedingSizeMock,
listFilesOlderThan: listFilesOlderThanMock,
};
});
export const deleteFilesMock: jest.MockedFn<typeof deleteFiles> = jest.fn();
jest.doMock('./fs', () => {
const actual = jest.requireActual('./fs');
return {
...actual,
deleteFiles: deleteFilesMock,
};
});

View file

@ -0,0 +1,141 @@
/*
* 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 {
listFilesExceedingSizeMock,
listFilesOlderThanMock,
deleteFilesMock,
} from './retention_policy.test.mocks';
import type { RetentionPolicyConfig } from '@kbn/core-logging-server';
import { rollingFileAppenderMocks } from '../mocks';
import { GenericRetentionPolicy, retentionPolicyConfigSchema } from './retention_policy';
describe('GenericRetentionPolicy', () => {
let context: ReturnType<typeof rollingFileAppenderMocks.createContext>;
let config: RetentionPolicyConfig;
beforeEach(() => {
deleteFilesMock.mockReset();
listFilesExceedingSizeMock.mockReset();
listFilesOlderThanMock.mockReset();
context = rollingFileAppenderMocks.createContext();
});
it('supports the maxFile directive', async () => {
config = retentionPolicyConfigSchema.validate({
maxFiles: 2,
});
context.getOrderedRolledFiles.mockResolvedValue(['file-1', 'file-2', 'file-3', 'file-4']);
const policy = new GenericRetentionPolicy(config, context);
await policy.apply();
expect(deleteFilesMock).toHaveBeenCalledTimes(1);
expect(deleteFilesMock).toHaveBeenCalledWith({
filesToDelete: ['file-3', 'file-4'],
});
});
it('supports the maxAccumulatedFileSize directive', async () => {
config = retentionPolicyConfigSchema.validate({
maxAccumulatedFileSize: '50b',
});
context.getOrderedRolledFiles.mockResolvedValue(['file-1', 'file-2', 'file-3', 'file-4']);
listFilesExceedingSizeMock.mockResolvedValue(['file-4']);
const policy = new GenericRetentionPolicy(config, context);
await policy.apply();
expect(listFilesOlderThanMock).not.toHaveBeenCalled();
expect(listFilesExceedingSizeMock).toHaveBeenCalledTimes(1);
expect(listFilesExceedingSizeMock).toHaveBeenCalledWith({
orderedFiles: ['file-1', 'file-2', 'file-3', 'file-4'],
maxSizeInBytes: config.maxAccumulatedFileSize!.getValueInBytes(),
});
expect(deleteFilesMock).toHaveBeenCalledTimes(1);
expect(deleteFilesMock).toHaveBeenCalledWith({
filesToDelete: ['file-4'],
});
});
it('supports the removeOlderThan directive', async () => {
config = retentionPolicyConfigSchema.validate({
removeOlderThan: '30d',
});
context.getOrderedRolledFiles.mockResolvedValue(['file-1', 'file-2', 'file-3', 'file-4']);
listFilesOlderThanMock.mockResolvedValue(['file-2', 'file-3', 'file-4']);
const policy = new GenericRetentionPolicy(config, context);
await policy.apply();
expect(listFilesExceedingSizeMock).not.toHaveBeenCalled();
expect(listFilesOlderThanMock).toHaveBeenCalledTimes(1);
expect(listFilesOlderThanMock).toHaveBeenCalledWith({
orderedFiles: ['file-1', 'file-2', 'file-3', 'file-4'],
duration: config.removeOlderThan!,
});
expect(deleteFilesMock).toHaveBeenCalledTimes(1);
expect(deleteFilesMock).toHaveBeenCalledWith({
filesToDelete: ['file-2', 'file-3', 'file-4'],
});
});
it('supports all directives at the same time', async () => {
config = retentionPolicyConfigSchema.validate({
maxFiles: 3,
removeOlderThan: '30d',
maxAccumulatedFileSize: '50b',
});
context.getOrderedRolledFiles.mockResolvedValue(['file-1', 'file-2', 'file-3', 'file-4']);
listFilesOlderThanMock.mockResolvedValue(['file-2']);
listFilesExceedingSizeMock.mockResolvedValue(['file-3']);
const policy = new GenericRetentionPolicy(config, context);
await policy.apply();
expect(listFilesExceedingSizeMock).toHaveBeenCalledTimes(1);
expect(listFilesExceedingSizeMock).toHaveBeenCalledWith({
orderedFiles: ['file-1', 'file-2', 'file-3', 'file-4'],
maxSizeInBytes: config.maxAccumulatedFileSize!.getValueInBytes(),
});
expect(listFilesOlderThanMock).toHaveBeenCalledTimes(1);
expect(listFilesOlderThanMock).toHaveBeenCalledWith({
orderedFiles: ['file-1', 'file-2', 'file-3', 'file-4'],
duration: config.removeOlderThan!,
});
expect(deleteFilesMock).toHaveBeenCalledTimes(1);
expect(deleteFilesMock).toHaveBeenCalledWith({
filesToDelete: ['file-4', 'file-3', 'file-2'],
});
});
it('do not call deleteFiles if no file should be deleted', async () => {
config = retentionPolicyConfigSchema.validate({
maxFiles: 5,
});
context.getOrderedRolledFiles.mockResolvedValue(['file-1', 'file-2', 'file-3', 'file-4']);
const policy = new GenericRetentionPolicy(config, context);
await policy.apply();
expect(deleteFilesMock).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,85 @@
/*
* 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 { dirname, join } from 'path';
import { schema } from '@kbn/config-schema';
import type { RetentionPolicyConfig } from '@kbn/core-logging-server';
import type { RollingFileContext } from '../rolling_file_context';
import { deleteFiles } from './fs';
import { listFilesExceedingSize, listFilesOlderThan } from './utils';
export const retentionPolicyConfigSchema = schema.object(
{
maxFiles: schema.maybe(schema.number({ min: 1, max: 365 })),
maxAccumulatedFileSize: schema.maybe(schema.byteSize()),
removeOlderThan: schema.maybe(schema.duration({ max: '365d' })),
},
{
validate: (config) => {
if (!config.maxFiles && !config.maxAccumulatedFileSize && !config.removeOlderThan) {
return 'Retention policy must define at least one directive: maxFiles, maxAccumulatedFileSize or removeOlderThan';
}
},
}
);
export interface RetentionPolicy {
/**
* Apply the configured policy, checking the existing log files bound to the appender
* and disposing of those that should.
*/
apply(): Promise<void>;
}
export class GenericRetentionPolicy implements RetentionPolicy {
private readonly logFileFolder;
constructor(
private readonly config: RetentionPolicyConfig,
private readonly context: RollingFileContext
) {
this.logFileFolder = dirname(this.context.filePath);
}
async apply() {
const { maxFiles, maxAccumulatedFileSize, removeOlderThan } = this.config;
const orderedFilesBaseNames = await this.context.getOrderedRolledFiles();
const absOrderedFiles = orderedFilesBaseNames.map((filepath) =>
join(this.logFileFolder, filepath)
);
const filesToDelete: Set<string> = new Set();
if (maxFiles) {
const exceedingFiles = absOrderedFiles.slice(maxFiles, absOrderedFiles.length);
exceedingFiles.forEach((file) => filesToDelete.add(file));
}
if (maxAccumulatedFileSize) {
const toRemoveByFileSize = await listFilesExceedingSize({
orderedFiles: absOrderedFiles,
maxSizeInBytes: maxAccumulatedFileSize.getValueInBytes(),
});
toRemoveByFileSize.forEach((file) => filesToDelete.add(file));
}
if (removeOlderThan) {
const toRemoveByAge = await listFilesOlderThan({
orderedFiles: absOrderedFiles,
duration: removeOlderThan,
});
toRemoveByAge.forEach((file) => filesToDelete.add(file));
}
if (filesToDelete.size > 0) {
await deleteFiles({
filesToDelete: [...filesToDelete],
});
}
}
}

View file

@ -0,0 +1,19 @@
/*
* 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 { getFileInfo } from './fs';
export const getFileInfoMock: jest.MockedFn<typeof getFileInfo> = jest.fn();
jest.doMock('./fs', () => {
const actual = jest.requireActual('./fs');
return {
...actual,
getFileInfo: getFileInfoMock,
};
});

View file

@ -0,0 +1,104 @@
/*
* 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 moment from 'moment-timezone';
import { getFileInfoMock } from './utils.test.mocks';
import { listFilesOlderThan, listFilesExceedingSize } from './utils';
describe('listFilesExceedingSize', () => {
beforeEach(() => {
getFileInfoMock.mockReset();
});
const mockWithSizeMap = (fileSizeMap: Record<string, number | undefined>) => {
getFileInfoMock.mockImplementation(async (fileName) => {
const fileSize = fileSizeMap[fileName];
if (fileSize !== undefined) {
return { exist: true, size: fileSize, mtime: new Date() };
}
return { exist: false };
});
};
it('returns an empty list if total accumulated size is lower than the limit', async () => {
mockWithSizeMap({
'file-1': 50,
'file-2': 50,
'file-3': 50,
});
const result = await listFilesExceedingSize({
orderedFiles: ['file-1', 'file-2', 'file-3'],
maxSizeInBytes: 200,
});
expect(result).toEqual([]);
});
it('returns the list of files over the limit, including the one that breached the limit', async () => {
mockWithSizeMap({
'file-1': 100,
'file-2': 100,
'file-3': 100,
'file-4': 100,
});
const result = await listFilesExceedingSize({
orderedFiles: ['file-1', 'file-2', 'file-3', 'file-4'],
maxSizeInBytes: 250,
});
expect(result).toEqual(['file-3', 'file-4']);
});
});
describe('listFilesOlderThan', () => {
beforeEach(() => {
getFileInfoMock.mockReset();
});
const mockWithMtime = (fileMtimeMap: Record<string, Date | undefined>) => {
getFileInfoMock.mockImplementation(async (fileName) => {
const fileDate = fileMtimeMap[fileName];
if (fileDate !== undefined) {
return { exist: true, size: 0, mtime: fileDate };
}
return { exist: false };
});
};
it('returns an empty list if total accumulated size is lower than the limit', async () => {
mockWithMtime({
'file-1': moment().add(-1, 'day').toDate(),
'file-2': moment().add(-10, 'day').toDate(),
'file-3': moment().add(-20, 'day').toDate(),
});
const result = await listFilesOlderThan({
orderedFiles: ['file-1', 'file-2', 'file-3'],
duration: moment.duration(15, 'day'),
});
expect(result).toEqual(['file-3']);
});
it('ignores files that were not found', async () => {
mockWithMtime({
'file-1': moment().add(-1, 'day').toDate(),
'file-2': moment().add(-20, 'day').toDate(),
'file-3': undefined,
});
const result = await listFilesOlderThan({
orderedFiles: ['file-1', 'file-2', 'file-3'],
duration: moment.duration(15, 'day'),
});
expect(result).toEqual(['file-2']);
});
});

View file

@ -0,0 +1,56 @@
/*
* 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 moment, { type Duration } from 'moment-timezone';
import { getFileInfo } from './fs';
export const listFilesExceedingSize = async ({
orderedFiles,
maxSizeInBytes,
}: {
orderedFiles: string[];
maxSizeInBytes: number;
}): Promise<string[]> => {
let currentSize = 0;
for (let i = 0; i < orderedFiles.length; i++) {
const filePath = orderedFiles[i];
const stats = await getFileInfo(filePath);
if (stats.exist) {
currentSize += stats.size;
if (currentSize > maxSizeInBytes) {
return orderedFiles.slice(i);
}
}
}
return [];
};
export const listFilesOlderThan = async ({
orderedFiles,
duration,
}: {
orderedFiles: string[];
duration: Duration;
}): Promise<string[]> => {
const filesOlderThanLimit = [];
const timeLimit = moment().subtract(duration).toDate().getTime();
for (let i = 0; i < orderedFiles.length; i++) {
const filePath = orderedFiles[i];
const stats = await getFileInfo(filePath);
if (stats.exist) {
if (stats.mtime.getTime() < timeLimit) {
filesOlderThanLimit.push(filePath);
}
}
}
return filesOlderThanLimit;
};

View file

@ -38,10 +38,20 @@ jest.doMock('./rolling_file_context', () => ({
RollingFileContext: RollingFileContextMock,
}));
export const createRetentionPolicyMock = jest.fn();
jest.doMock('./retention', () => {
const actual = jest.requireActual('./retention');
return {
...actual,
createRetentionPolicy: createRetentionPolicyMock,
};
});
export const resetAllMocks = () => {
LayoutsMock.create.mockReset();
createTriggeringPolicyMock.mockReset();
createRollingStrategyMock.mockReset();
createRetentionPolicyMock.mockReset();
RollingFileManagerMock.mockReset();
RollingFileContextMock.mockReset();
};

View file

@ -9,6 +9,7 @@
import {
createRollingStrategyMock,
createTriggeringPolicyMock,
createRetentionPolicyMock,
LayoutsMock,
resetAllMocks,
RollingFileContextMock,
@ -38,6 +39,9 @@ const config: RollingFileAppenderConfig = {
max: 5,
pattern: '-%i',
},
retention: {
maxFiles: 7,
},
};
const createLogRecord = (parts: Partial<LogRecord> = {}): LogRecord => ({
@ -72,6 +76,7 @@ describe('RollingFileAppender', () => {
let layout: ReturnType<typeof rollingFileAppenderMocks.createLayout>;
let strategy: ReturnType<typeof rollingFileAppenderMocks.createStrategy>;
let policy: ReturnType<typeof rollingFileAppenderMocks.createPolicy>;
let retention: ReturnType<typeof rollingFileAppenderMocks.createRetentionPolicy>;
let context: ReturnType<typeof rollingFileAppenderMocks.createContext>;
let fileManager: ReturnType<typeof rollingFileAppenderMocks.createFileManager>;
@ -85,6 +90,9 @@ describe('RollingFileAppender', () => {
strategy = rollingFileAppenderMocks.createStrategy();
createRollingStrategyMock.mockReturnValue(strategy);
retention = rollingFileAppenderMocks.createRetentionPolicy();
createRetentionPolicyMock.mockReturnValue(retention);
context = rollingFileAppenderMocks.createContext('file-path');
RollingFileContextMock.mockImplementation(() => context);
@ -111,6 +119,9 @@ describe('RollingFileAppender', () => {
expect(createTriggeringPolicyMock).toHaveBeenCalledTimes(1);
expect(createTriggeringPolicyMock).toHaveBeenCalledWith(config.policy, context);
expect(createRetentionPolicyMock).toHaveBeenCalledTimes(1);
expect(createRetentionPolicyMock).toHaveBeenCalledWith(config.retention, context);
expect(createRollingStrategyMock).toHaveBeenCalledTimes(1);
expect(createRollingStrategyMock).toHaveBeenCalledWith(config.strategy, context);
});
@ -189,6 +200,21 @@ describe('RollingFileAppender', () => {
expect(fileManager.closeStream).toHaveBeenCalledTimes(1);
});
it('triggers the retention policy once the rollout is complete', async () => {
const { promise, resolve } = createPromiseResolver();
strategy.rollout.mockReturnValue(promise);
const log = createLogRecord({ message: '1' });
appender.append(log);
expect(retention.apply).not.toHaveBeenCalled();
resolve();
await nextTick();
expect(retention.apply).toHaveBeenCalledTimes(1);
});
it('logs the event once the rollout is complete', async () => {
const { promise, resolve } = createPromiseResolver();
strategy.rollout.mockReturnValue(promise);

View file

@ -7,12 +7,26 @@
*/
import { schema } from '@kbn/config-schema';
import { LogRecord, Layout, DisposableAppender } from '@kbn/logging';
import type { LogRecord, Layout, DisposableAppender } from '@kbn/logging';
import type { RollingFileAppenderConfig } from '@kbn/core-logging-server';
import { Layouts } from '../../layouts/layouts';
import { BufferAppender } from '../buffer/buffer_appender';
import { createTriggeringPolicy, triggeringPolicyConfigSchema, TriggeringPolicy } from './policies';
import { RollingStrategy, createRollingStrategy, rollingStrategyConfigSchema } from './strategies';
import {
createTriggeringPolicy,
triggeringPolicyConfigSchema,
type TriggeringPolicy,
} from './policies';
import {
createRollingStrategy,
rollingStrategyConfigSchema,
type RollingStrategy,
} from './strategies';
import {
createRetentionPolicy,
mergeRetentionPolicyConfig,
retentionPolicyConfigSchema,
type RetentionPolicy,
} from './retention';
import { RollingFileManager } from './rolling_file_manager';
import { RollingFileContext } from './rolling_file_context';
@ -27,6 +41,7 @@ export class RollingFileAppender implements DisposableAppender {
fileName: schema.string(),
policy: triggeringPolicyConfigSchema,
strategy: rollingStrategyConfigSchema,
retention: schema.maybe(retentionPolicyConfigSchema),
});
private isRolling = false;
@ -36,8 +51,9 @@ export class RollingFileAppender implements DisposableAppender {
private readonly layout: Layout;
private readonly context: RollingFileContext;
private readonly fileManager: RollingFileManager;
private readonly policy: TriggeringPolicy;
private readonly strategy: RollingStrategy;
private readonly triggeringPolicy: TriggeringPolicy;
private readonly rollingStrategy: RollingStrategy;
private readonly retentionPolicy: RetentionPolicy;
private readonly buffer: BufferAppender;
constructor(config: RollingFileAppenderConfig) {
@ -45,8 +61,12 @@ export class RollingFileAppender implements DisposableAppender {
this.context.refreshFileInfo();
this.fileManager = new RollingFileManager(this.context);
this.layout = Layouts.create(config.layout);
this.policy = createTriggeringPolicy(config.policy, this.context);
this.strategy = createRollingStrategy(config.strategy, this.context);
this.triggeringPolicy = createTriggeringPolicy(config.policy, this.context);
this.rollingStrategy = createRollingStrategy(config.strategy, this.context);
this.retentionPolicy = createRetentionPolicy(
mergeRetentionPolicyConfig(config.retention, config.strategy),
this.context
);
this.buffer = new BufferAppender();
}
@ -96,8 +116,9 @@ export class RollingFileAppender implements DisposableAppender {
}
this.isRolling = true;
try {
await this.strategy.rollout();
await this.rollingStrategy.rollout();
await this.fileManager.closeStream();
await this.retentionPolicy.apply();
} catch (e) {
// eslint-disable-next-line no-console
console.error('[RollingFileAppender]: error while rolling file: ', e);
@ -129,6 +150,6 @@ export class RollingFileAppender implements DisposableAppender {
* Checks if the current event should trigger a rollout
*/
private needRollout(record: LogRecord) {
return this.policy.isTriggeringEvent(record);
return this.triggeringPolicy.isTriggeringEvent(record);
}
}

View file

@ -8,6 +8,8 @@
import { statSync } from 'fs';
export type GetOrderedRolledFileFn = () => Promise<string[]>;
/**
* Context shared between the rolling file manager, policy and strategy.
*/
@ -36,4 +38,17 @@ export class RollingFileContext {
this.currentFileSize = 0;
}
}
#orderedRolledFileFn?: GetOrderedRolledFileFn;
public async getOrderedRolledFiles(): Promise<string[]> {
if (this.#orderedRolledFileFn) {
return this.#orderedRolledFileFn();
}
throw new Error('orderedRolledFileFn not registered on the context');
}
public setOrderedRolledFileFn(fn: GetOrderedRolledFileFn) {
this.#orderedRolledFileFn = fn;
}
}

View file

@ -7,14 +7,12 @@
*/
export const getOrderedRolledFilesMock = jest.fn();
export const deleteFilesMock = jest.fn();
export const rollPreviousFilesInOrderMock = jest.fn();
export const rollCurrentFileMock = jest.fn();
export const shouldSkipRolloutMock = jest.fn();
jest.doMock('./rolling_tasks', () => ({
getOrderedRolledFiles: getOrderedRolledFilesMock,
deleteFiles: deleteFilesMock,
rollPreviousFilesInOrder: rollPreviousFilesInOrderMock,
rollCurrentFile: rollCurrentFileMock,
shouldSkipRollout: shouldSkipRolloutMock,
@ -23,7 +21,6 @@ jest.doMock('./rolling_tasks', () => ({
export const resetAllMock = () => {
shouldSkipRolloutMock.mockReset();
getOrderedRolledFilesMock.mockReset();
deleteFilesMock.mockReset();
rollPreviousFilesInOrderMock.mockReset();
rollCurrentFileMock.mockReset();
};

View file

@ -10,7 +10,6 @@ import { join } from 'path';
import {
resetAllMock,
shouldSkipRolloutMock,
deleteFilesMock,
getOrderedRolledFilesMock,
rollCurrentFileMock,
rollPreviousFilesInOrderMock,
@ -42,6 +41,11 @@ describe('NumericRollingStrategy', () => {
resetAllMock();
});
it('calls `context.setOrderedRolledFileFn` in constructor', () => {
expect(context.setOrderedRolledFileFn).toHaveBeenCalledTimes(1);
expect(context.setOrderedRolledFileFn).toHaveBeenCalledWith(expect.any(Function));
});
it('calls `getOrderedRolledFiles` with the correct parameters', async () => {
await strategy.rollout();
@ -53,23 +57,6 @@ describe('NumericRollingStrategy', () => {
});
});
it('calls `deleteFiles` with the correct files', async () => {
getOrderedRolledFilesMock.mockResolvedValue([
'kibana.1.log',
'kibana.2.log',
'kibana.3.log',
'kibana.4.log',
]);
await strategy.rollout();
expect(deleteFilesMock).toHaveBeenCalledTimes(1);
expect(deleteFilesMock).toHaveBeenCalledWith({
filesToDelete: ['kibana.3.log', 'kibana.4.log'],
logFileFolder,
});
});
it('calls `rollPreviousFilesInOrder` with the correct files', async () => {
getOrderedRolledFilesMock.mockResolvedValue([
'kibana.1.log',
@ -82,7 +69,7 @@ describe('NumericRollingStrategy', () => {
expect(rollPreviousFilesInOrderMock).toHaveBeenCalledTimes(1);
expect(rollPreviousFilesInOrderMock).toHaveBeenCalledWith({
filesToRoll: ['kibana.1.log', 'kibana.2.log'],
filesToRoll: ['kibana.1.log', 'kibana.2.log', 'kibana.3.log', 'kibana.4.log'],
logFileFolder,
logFileBaseName,
pattern,
@ -116,24 +103,14 @@ describe('NumericRollingStrategy', () => {
await strategy.rollout();
const deleteFilesCall = deleteFilesMock.mock.invocationCallOrder[0];
const rollPreviousFilesInOrderCall = rollPreviousFilesInOrderMock.mock.invocationCallOrder[0];
const rollCurrentFileCall = rollCurrentFileMock.mock.invocationCallOrder[0];
const refreshFileInfoCall = context.refreshFileInfo.mock.invocationCallOrder[0];
expect(deleteFilesCall).toBeLessThan(rollPreviousFilesInOrderCall);
expect(rollPreviousFilesInOrderCall).toBeLessThan(rollCurrentFileCall);
expect(rollCurrentFileCall).toBeLessThan(refreshFileInfoCall);
});
it('do not calls `deleteFiles` if no file should be deleted', async () => {
getOrderedRolledFilesMock.mockResolvedValue(['kibana.1.log', 'kibana.2.log']);
await strategy.rollout();
expect(deleteFilesMock).not.toHaveBeenCalled();
});
it('do not calls `rollPreviousFilesInOrder` if no file should be rolled', async () => {
getOrderedRolledFilesMock.mockResolvedValue([]);
@ -154,7 +131,6 @@ describe('NumericRollingStrategy', () => {
await strategy.rollout();
expect(getOrderedRolledFilesMock).not.toHaveBeenCalled();
expect(deleteFilesMock).not.toHaveBeenCalled();
expect(rollPreviousFilesInOrderMock).not.toHaveBeenCalled();
expect(rollCurrentFileMock).not.toHaveBeenCalled();
expect(context.refreshFileInfo).not.toHaveBeenCalled();

View file

@ -14,7 +14,6 @@ import { RollingFileContext } from '../../rolling_file_context';
import {
shouldSkipRollout,
getOrderedRolledFiles,
deleteFiles,
rollCurrentFile,
rollPreviousFilesInOrder,
} from './rolling_tasks';
@ -46,7 +45,8 @@ export const numericRollingStrategyConfigSchema = schema.object({
* strategy:
* type: numeric
* pattern: "-%i"
* max: 2
* retention:
* maxFiles: 2
* ```
* - During the first rollover kibana.log is renamed to kibana-1.log. A new kibana.log file is created and starts
* being written to.
@ -69,6 +69,15 @@ export class NumericRollingStrategy implements RollingStrategy {
this.logFilePath = this.context.filePath;
this.logFileBaseName = basename(this.context.filePath);
this.logFileFolder = dirname(this.context.filePath);
context.setOrderedRolledFileFn(this.getOrderedRolledFiles.bind(this));
}
private async getOrderedRolledFiles() {
return await getOrderedRolledFiles({
logFileFolder: this.logFileFolder,
logFileBaseName: this.logFileBaseName,
pattern: this.config.pattern,
});
}
async rollout() {
@ -82,20 +91,15 @@ export class NumericRollingStrategy implements RollingStrategy {
}
// get the files matching the pattern in the folder, and sort them by `%i` value
const orderedFiles = await getOrderedRolledFiles({
logFileFolder,
logFileBaseName,
pattern,
});
const filesToRoll = orderedFiles.slice(0, this.config.max - 1);
const filesToDelete = orderedFiles.slice(filesToRoll.length, orderedFiles.length);
const orderedFilesToRoll = await this.getOrderedRolledFiles();
if (filesToDelete.length > 0) {
await deleteFiles({ logFileFolder, filesToDelete });
}
if (filesToRoll.length > 0) {
await rollPreviousFilesInOrder({ filesToRoll, logFileFolder, logFileBaseName, pattern });
if (orderedFilesToRoll.length > 0) {
await rollPreviousFilesInOrder({
filesToRoll: orderedFilesToRoll,
logFileFolder,
logFileBaseName,
pattern,
});
}
await rollCurrentFile({ pattern, logFileBaseName, logFileFolder });

View file

@ -7,20 +7,17 @@
*/
export const readdirMock = jest.fn();
export const unlinkMock = jest.fn();
export const renameMock = jest.fn();
export const accessMock = jest.fn();
jest.doMock('fs/promises', () => ({
readdir: readdirMock,
unlink: unlinkMock,
rename: renameMock,
access: accessMock,
}));
export const clearAllMocks = () => {
readdirMock.mockClear();
unlinkMock.mockClear();
renameMock.mockClear();
accessMock.mockClear();
};

View file

@ -7,18 +7,11 @@
*/
import { join } from 'path';
import {
accessMock,
readdirMock,
renameMock,
unlinkMock,
clearAllMocks,
} from './rolling_tasks.test.mocks';
import { accessMock, readdirMock, renameMock, clearAllMocks } from './rolling_tasks.test.mocks';
import {
shouldSkipRollout,
rollCurrentFile,
rollPreviousFilesInOrder,
deleteFiles,
getOrderedRolledFiles,
} from './rolling_tasks';
@ -97,26 +90,6 @@ describe('NumericRollingStrategy tasks', () => {
});
});
describe('deleteFiles', () => {
it('calls `unlink` once for each file', async () => {
await deleteFiles({
logFileFolder: 'log-folder',
filesToDelete: ['file-a', 'file-b', 'file-c'],
});
expect(unlinkMock).toHaveBeenCalledTimes(3);
});
it('calls `unlink` with the correct parameters', async () => {
await deleteFiles({
logFileFolder: 'log-folder',
filesToDelete: ['file-a', 'file-b'],
});
expect(unlinkMock).toHaveBeenNthCalledWith(1, join('log-folder', 'file-a'));
expect(unlinkMock).toHaveBeenNthCalledWith(2, join('log-folder', 'file-b'));
});
});
describe('getOrderedRolledFiles', () => {
it('returns the rolled files matching the pattern in order', async () => {
readdirMock.mockResolvedValue([

View file

@ -7,7 +7,7 @@
*/
import { join } from 'path';
import { readdir, unlink, access } from 'fs/promises';
import { readdir, access } from 'fs/promises';
import { getFileNameMatcher, getRollingFileName } from './pattern_matcher';
import { moveFile } from './utils';
@ -47,16 +47,6 @@ export const getOrderedRolledFiles = async ({
.map(({ fileName }) => fileName);
};
export const deleteFiles = async ({
logFileFolder,
filesToDelete,
}: {
logFileFolder: string;
filesToDelete: string[];
}) => {
await Promise.all(filesToDelete.map((fileToDelete) => unlink(join(logFileFolder, fileToDelete))));
};
export const rollPreviousFilesInOrder = async ({
filesToRoll,
logFileFolder,

View file

@ -23,4 +23,5 @@ export type {
RewritePolicyConfig,
MetaRewritePolicyConfigProperty,
MetaRewritePolicyConfig,
RetentionPolicyConfig,
} from './src/appenders';

View file

@ -26,6 +26,7 @@ export type {
TimeIntervalTriggeringPolicyConfig,
NumericRollingStrategyConfig,
RollingStrategyConfig,
RetentionPolicyConfig,
} from './rolling_file';
/** @public */

View file

@ -32,6 +32,10 @@ export interface RollingFileAppenderConfig {
* The {@link RollingStrategy | rollout strategy} to use for rolling.
*/
strategy: RollingStrategyConfig;
/**
* The {@link RetentionPolicy | retention strategy} to use to know which files to keep.
*/
retention?: RetentionPolicyConfig;
}
/**
@ -106,6 +110,14 @@ export interface NumericRollingStrategyConfig {
/**
* The maximum number of files to keep. Once this number is reached, oldest
* files will be deleted. Defaults to `7`
*
* @deprecated use retention policy instead
*/
max: number;
}
export interface RetentionPolicyConfig {
maxFiles?: number;
maxAccumulatedFileSize?: ByteSizeValue;
removeOlderThan?: Duration;
}

View file

@ -18,7 +18,8 @@ tags: ['kibana','dev', 'contributor', 'api docs']
- [Appenders](#appenders)
- [Rolling File Appender](#rolling-file-appender)
- [Triggering Policies](#triggering-policies)
- [Rolling strategies](#rolling-strategies)
- [Rolling Strategies](#rolling-strategies)
- [Retention Policies](#retention-policies)
- [Configuration](#configuration)
- [Usage](#usage)
- [Logging config migration](#logging-config-migration)
@ -270,15 +271,13 @@ The default value is `true`.
#### Rolling strategies
The rolling strategy determines how the rollover should occur: both the naming of the rolled files,
and their retention policy.
The rolling strategy determines how the naming of the rolled file should be performed.
There is currently one strategy supported: `numeric`.
##### NumericRollingStrategy
This strategy will suffix the file with a given pattern when rolling,
and will retains a fixed amount of rolled files.
This strategy will suffix the file with a given pattern when rolling.
```yaml
logging:
@ -291,7 +290,8 @@ logging:
strategy:
type: numeric
pattern: '-%i'
max: 2
retention:
maxFiles: 2
layout:
type: pattern
```
@ -323,6 +323,86 @@ The maximum number of files to keep. Once this number is reached, oldest files w
The default value is `7`
**deprecated**: Please use a retention policy instead.
#### Retention Policies
The retention policy defines when the rolled files must be deleted.
##### maxFiles
Retention policy that will keep, at max, the defined number of files (ordered from the most recent to the oldest one)
and delete the others.
This is the same behavior as what was previously achieved via the deprecated `strategy.max` field.
**Example**: Appender configured to keep `5` rolled files:
```yaml
logging:
appenders:
rolling-file:
type: rolling-file
fileName: /var/logs/kibana.log
layout:
type: pattern
policy:
// ...
strategy:
// ...
retention:
maxFiles: 5
```
##### maxAccumulatedFileSize
Retention policy based on the size on disk used by the rolled files. Size count will be
performed from the most recent file to the oldest one, and will delete all files
(including the one breaching the threshold) over the limit. Only the size from the rolled files is taken into
account (the active file is ignored).
*Note:* The field supports the following optional suffixes: `b`, `kb`, `mb`, `gb` and `tb` (case-insensitive). The default suffix is `b`
**Example**: Appender configured to keep 5GB of rolled logs at maximum:
```yaml
logging:
appenders:
rolling-file:
type: rolling-file
fileName: /var/logs/kibana.log
layout:
type: pattern
policy:
// ...
strategy:
// ...
retention:
maxAccumulatedFileSize: "5GB"
```
##### removeOlderThan
Retention policy based on the time (modification date - `mtime`) the files were rolled, and that will delete all files
that are older than the specified duration.
*Note:* The field supports the following optional suffixes: `ms`, `s`, `m`, `h`, `d`, `w`, `M` and `Y`. The default suffix is `ms`.
**Example**: Appender configured to delete rolled files after 30 days:
```yaml
logging:
appenders:
rolling-file:
type: rolling-file
fileName: /var/logs/kibana.log
layout:
type: pattern
policy:
// ...
strategy:
// ...
retention:
removeOlderThan: "30d"
```
### Rewrite Appender
*This appender is currently considered experimental and is not intended

View file

@ -60,7 +60,7 @@ describe('RollingFileAppender', () => {
const expectedFileContent = (indices: number[]) => indices.map(message).join('\n') + '\n';
describe('`size-limit` policy with `numeric` strategy', () => {
it('rolls the log file in the correct order', async () => {
it('supports the deprecated `strategy.max` field', async () => {
root = createRoot({
type: 'rolling-file',
fileName: logFile,
@ -106,7 +106,7 @@ describe('RollingFileAppender', () => {
expect(await getFileContent('kibana.2.log')).toEqual(expectedFileContent([1, 2, 3]));
});
it('only keep the correct number of files', async () => {
it('maxFile retention: only keep the correct number of files', async () => {
root = createRoot({
type: 'rolling-file',
fileName: logFile,
@ -120,9 +120,11 @@ describe('RollingFileAppender', () => {
},
strategy: {
type: 'numeric',
max: 2,
pattern: '-%i',
},
retention: {
maxFiles: 2,
},
});
await root.preboot();
await root.setup();
@ -153,6 +155,107 @@ describe('RollingFileAppender', () => {
expect(await getFileContent('kibana-1.log')).toEqual(expectedFileContent([5, 6]));
expect(await getFileContent('kibana-2.log')).toEqual(expectedFileContent([3, 4]));
});
it('maxAccumulatedFileSize retention: only keep the correct number of files', async () => {
root = createRoot({
type: 'rolling-file',
fileName: logFile,
layout: {
type: 'pattern',
pattern: '%message',
},
policy: {
type: 'size-limit',
size: '60b',
},
strategy: {
type: 'numeric',
pattern: '-%i',
},
retention: {
maxAccumulatedFileSize: '100b',
},
});
await root.preboot();
await root.setup();
const logger = root.logger.get('test.rolling.file');
// size = 60b, message.length ~= 40b, should roll every 2 message
// last file - 'kibana-3.log' (which will be removed during rolling)
logger.info(message(1));
logger.info(message(2));
// roll - 'kibana-2.log' (which will be removed during rolling)
logger.info(message(3));
logger.info(message(4));
// roll - 'kibana-1.log'
logger.info(message(5));
logger.info(message(6));
// roll - 'kibana.log'
logger.info(message(7));
logger.info(message(8));
await flush();
const files = await readdir(testDir);
expect(files.sort()).toEqual(['kibana-1.log', 'kibana.log']);
expect(await getFileContent('kibana.log')).toEqual(expectedFileContent([7, 8]));
expect(await getFileContent('kibana-1.log')).toEqual(expectedFileContent([5, 6]));
});
it('removeOlderThan retention: only keep the correct files', async () => {
root = createRoot({
type: 'rolling-file',
fileName: logFile,
layout: {
type: 'pattern',
pattern: '%message',
},
policy: {
type: 'size-limit',
size: '60b',
},
strategy: {
type: 'numeric',
pattern: '-%i',
},
retention: {
removeOlderThan: '2s',
},
});
await root.preboot();
await root.setup();
const logger = root.logger.get('test.rolling.file');
// size = 60b, message.length ~= 40b, should roll every 2 message
// last file - 'kibana-3.log' (which will be removed during rolling)
logger.info(message(1));
logger.info(message(2));
// roll - 'kibana-2.log' (which will be removed during rolling)
logger.info(message(3));
logger.info(message(4));
await delay(2500);
// roll - 'kibana-1.log'
logger.info(message(5));
logger.info(message(6));
// roll - 'kibana.log'
logger.info(message(7));
logger.info(message(8));
await flush();
const files = await readdir(testDir);
expect(files.sort()).toEqual(['kibana-1.log', 'kibana.log']);
expect(await getFileContent('kibana.log')).toEqual(expectedFileContent([7, 8]));
expect(await getFileContent('kibana-1.log')).toEqual(expectedFileContent([5, 6]));
});
});
describe('`time-interval` policy with `numeric` strategy', () => {