Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/Security-Reachability.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:
permissions:
issues: write
contents: read
pull-requests: read
pull-requests: write

# Set of commands to run to compute the reachability of CVEs in the codebase
steps:
Expand Down Expand Up @@ -84,7 +84,7 @@ jobs:
# validate:package:skip-reachability command so workflow validation does not modify its checkout.
- name: Install Socket CLI
background: true
run: sfw pip install socketsecurity==2.9.6 uv --upgrade
run: sfw pip install socketsecurity==2.10.0 uv --upgrade

# Bring job back to sync execution by awaiting for all async jobs to finish before continuing
- name: Steps - Convert Back To Synchronous Execution - Packages Updates/Setup
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,4 @@ dist
coverage-output.txt
coverage-summary.md
/.idea
azurite
4 changes: 4 additions & 0 deletions .npmignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,7 @@ package-lock.json
# Test artifacts that could be emitted into the build directory
bin/test
.idea

# Test application
/bin/test-app
/azurite
1 change: 1 addition & 0 deletions LogEngine.code-workspace
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
}
],
"settings": {
"azurite.location": "azurite/",
"files.associations": {
"*.tsconfig.json": "jsonc",
"coverage.tsconfig.json": "jsonc",
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "0.0.10",
"version": "0.0.11",
"name": "@software-hardware-integration-lab/log-engine",
"description": "Logging engine with ability to add plugins for any destination",
"author": {
Expand Down Expand Up @@ -34,7 +34,8 @@
"validate:package:skip-reachability": "node ./scripts/validate-audit-signatures.ts && npm run lint && npm run coverage && npm run build:prod && node ./scripts/validate-package.ts",
"update:reachability-pin": "node ./scripts/update-reachability-pin.ts",
"prepack": "npm run validate:package",
"postinstall": "ts-patch install -s"
"postinstall": "ts-patch install -s",
"testapp": "npm run build:prod && node ./bin/test-app/entry-point.js"
},
"packageManager": "npm@12.1.0",
"devEngines": {
Expand Down
27 changes: 15 additions & 12 deletions src/LogEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,11 +304,12 @@ export class LogEngine {
/** Point in time snapshot of the current enabled plugins to avoid race conditions. */
const pluginsSnapshot = [...this.#pluginList];

// Iterate through each registered plugin and await its processing of the log entry.
for (const plugin of pluginsSnapshot) {
// Await the plugin operation to process the log entry before moving on to the next plugin, ensuring sequential processing.
await LogEngine.#logErrorHandle(() => plugin.log(logEntry));
}
/*
* Dispatch to all plugins concurrently; await the batch so callers know every
* plugin has settled before this resolves.
*/
await Promise.all(pluginsSnapshot.map((plugin) => LogEngine
.#logErrorHandle(plugin.id, () => plugin.log(logEntry))));
}

/**
Expand All @@ -323,24 +324,26 @@ export class LogEngine {
/** Point in time snapshot of the current enabled plugins to avoid race conditions. */
const pluginsSnapshot = [...this.#pluginList];

// Iterate through each registered plugin and await its processing of the audit log entry.
for (const plugin of pluginsSnapshot) {
// Await the plugin operation to process the audit log entry before moving on to the next plugin, ensuring sequential processing.
await LogEngine.#logErrorHandle(() => plugin.auditLog(logEntry));
}
/*
* Dispatch to all plugins concurrently; await the batch so callers know every
* plugin has settled before this resolves.
*/
await Promise.all(pluginsSnapshot.map((plugin) => LogEngine
.#logErrorHandle(plugin.id, () => plugin.auditLog(logEntry))));
}

/**
* Executes a plugin logging operation and reports any failure without
* interrupting delivery to remaining plugins.
* @param pluginId The plugin identifier.
* @param logFunction The asynchronous plugin logging operation to execute.
* @returns A promise that resolves after the operation completes or its failure is reported.
*/
static async #logErrorHandle(logFunction: () => Promise<void>): Promise<void> {
static async #logErrorHandle(pluginId: string, logFunction: () => Promise<void>): Promise<void> {
try {
await logFunction();
} catch (error: unknown) {
LogEngine.#reportInternalError(`Logging plugin failed: ${ error instanceof Error ? error.message : String(error) }`);
LogEngine.#reportInternalError(`Logging plugin '${ pluginId }' failed: ${ error instanceof Error ? error.message : String(error) }`);
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,4 @@ export type {

export type { LoggingPluginContract } from './interfaces/plugins/LoggingPlugin.js';

export type { Logger } from './Logger.js';
export { Logger } from './Logger.js';
8 changes: 7 additions & 1 deletion src/interfaces/plugins/AzureStorageDestination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export interface AzureStorageDestinationOptions extends LoggingPluginConfigurati
'maxAppendBlockBytes'?: number & tags.Minimum<1>;
/** Maximum number of blocks written to a single append blob before rotating to a suffixed blob. */
'maxBlocksPerBlob'?: number & tags.Minimum<1> & tags.Maximum<50_000>;
/** The minutes duration that a log file will be written to before creating a new log file. */
'rotationIntervalMinutes'?: number & tags.Minimum<1> & tags.Maximum<1_440>;
}

/** Fully resolved options used internally by the Azure Storage destination. */
Expand All @@ -44,6 +46,8 @@ export interface ResolvedAzureStorageDestinationOptions extends LoggingPluginCon
'maxAppendBlockBytes': number & tags.Minimum<1>;
/** Maximum number of blocks written to a single append blob before rotating to a suffixed blob. */
'maxBlocksPerBlob': number & tags.Minimum<1> & tags.Maximum<50_000>;
/** The minutes duration that a log file will be written to before creating a new log file. */
'rotationIntervalMinutes': number & tags.Minimum<1> & tags.Maximum<1_440>;
}

/** Static default options for Azure Storage append-blob logging. */
Expand All @@ -52,5 +56,7 @@ export const DEFAULT_AZURE_STORAGE_DESTINATION_OPTIONS: ResolvedAzureStorageDest
// The stable limit supported by all Append Blob service API versions.
'maxAppendBlockBytes': 4 * 1024 * 1024,
// Azure's hard append-blob limit; rotating at this point uses the full available capacity.
'maxBlocksPerBlob': 50_000
'maxBlocksPerBlob': 50_000,
// Default to an hourly log file.
'rotationIntervalMinutes': 60
};
52 changes: 32 additions & 20 deletions src/plugins/AzureStorageDestination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ interface PendingLogRecord {

interface LogTypeCollection {
'activeBlob': AzureAppendBlobClientLike | undefined;
'activeBlobDate': number | undefined;
'activeWindowStart': number | undefined;
/** Suffix applied to the blob name when rotating within the same hour due to the block-count limit. */
'blobSuffix': number;
'blobContainer': AzureBlobContainerLike | undefined;
Expand Down Expand Up @@ -49,7 +49,7 @@ export class AzureStorageDestination extends LoggingPlugin {

this.#operationalCollection = {
'activeBlob': void 0,
'activeBlobDate': void 0,
'activeWindowStart': void 0,
'blobContainer': operationalLogContainer,
'blobSuffix': 0,
'blockCount': 0,
Expand All @@ -59,7 +59,7 @@ export class AzureStorageDestination extends LoggingPlugin {

this.#auditCollection = {
'activeBlob': void 0,
'activeBlobDate': void 0,
'activeWindowStart': void 0,
'blobContainer': auditLogContainer,
'blobSuffix': 0,
'blockCount': 0,
Expand Down Expand Up @@ -193,13 +193,13 @@ export class AzureStorageDestination extends LoggingPlugin {
if (this.#operationalCollection) {
this.#operationalCollection.activeBlob = void 0;

this.#operationalCollection.activeBlobDate = void 0;
this.#operationalCollection.activeWindowStart = void 0;
}

if (this.#auditCollection) {
this.#auditCollection.activeBlob = void 0;

this.#auditCollection.activeBlobDate = void 0;
this.#auditCollection.activeWindowStart = void 0;
}
});
}
Expand Down Expand Up @@ -312,20 +312,20 @@ export class AzureStorageDestination extends LoggingPlugin {
* @param type Discriminator identifying which collection is being rotated.
*/
async #ensureActiveBlob(collection: LogTypeCollection, type: 'audit' | 'operational'): Promise<void> {
const activeHour = new Date().setMinutes(0, 0, 0);
const activeWindow = this.#getActiveWindowStart();

const isNewHour = collection.activeBlobDate === void 0 || activeHour > collection.activeBlobDate;
const isNewWindow = collection.activeWindowStart === void 0 || activeWindow > collection.activeWindowStart;

const isBlockLimitReached = collection.blockCount >= this.#appliedOptions.maxBlocksPerBlob;

if (!isNewHour && !isBlockLimitReached) {
if (!isNewWindow && !isBlockLimitReached) {
return;
}

// Reset the suffix on a new hour; otherwise advance it to rotate within the same hour.
let blobSuffix = isNewHour ? 0 : collection.blobSuffix + 1;
let blobSuffix = isNewWindow ? 0 : collection.blobSuffix + 1;

let openedBlob = await this.#openBlob(type, activeHour, blobSuffix);
let openedBlob = await this.#openBlob(type, activeWindow, blobSuffix);

let rotationAttempts = 0;

Expand All @@ -339,14 +339,14 @@ export class AzureStorageDestination extends LoggingPlugin {

blobSuffix += 1;

openedBlob = await this.#openBlob(type, activeHour, blobSuffix);
openedBlob = await this.#openBlob(type, activeWindow, blobSuffix);
}

// eslint-disable-next-line require-atomic-updates -- #ensureActiveBlob only runs within a single-flight flush per collection.
collection.activeBlob = openedBlob.blobClient;

// eslint-disable-next-line require-atomic-updates -- #ensureActiveBlob only runs within a single-flight flush per collection.
collection.activeBlobDate = activeHour;
collection.activeWindowStart = activeWindow;

// eslint-disable-next-line require-atomic-updates -- #ensureActiveBlob only runs within a single-flight flush per collection.
collection.blobSuffix = blobSuffix;
Expand All @@ -355,6 +355,20 @@ export class AzureStorageDestination extends LoggingPlugin {
collection.blockCount = openedBlob.committedBlockCount;
}

/**
* Calculates the start of the rotation window that the current time falls within, anchored to UTC midnight.
* @returns Millisecond timestamp of the start of the active rotation window.
*/
#getActiveWindowStart(): number {
const now = Date.now();

const intervalMs = this.#appliedOptions.rotationIntervalMinutes * 60_000;

const dayStart = new Date(now).setUTCHours(0, 0, 0, 0);

return dayStart + (Math.floor((now - dayStart) / intervalMs) * intervalMs);
}

/**
* Groups queued records into batches that each fit within the configured byte limit.
* @param batch Queued records awaiting a flush.
Expand Down Expand Up @@ -395,25 +409,23 @@ export class AzureStorageDestination extends LoggingPlugin {
* Creates or reopens the append blob for a given hour and suffix, recovering its true committed block
* count when it already existed so callers can detect a blob that is already at capacity.
* @param type Discriminator identifying which collection is being opened.
* @param activeHour Millisecond timestamp of the top of the hour the blob belongs to.
* @param windowStart Millisecond timestamp of the start of the time window the blob belongs to.
* @param suffix Rotation suffix applied when opening a blob other than the first one for the hour.
* @returns The opened blob client, if the collection's container is available, alongside its true committed block count.
*/
async #openBlob(type: 'audit' | 'operational', activeHour: number, suffix: number): Promise<{
async #openBlob(type: 'audit' | 'operational', windowStart: number, suffix: number): Promise<{
'blobClient': AzureAppendBlobClientLike | undefined;
'committedBlockCount': number;
}> {
const activeHourDate = new Date(activeHour);

const isoValue = activeHourDate.toISOString();
const isoValue = new Date(windowStart).toISOString();

// Format the blob name based on the current hour.
const datePrefix = `${ isoValue.slice(0, 10) }${ isoValue.slice(11, 13) }`.replaceAll('-', '');
// YYYYMMDDHHmm — minute resolution is required for sub-hourly rotation intervals.
const datePrefix = `${ isoValue.slice(0, 10).replaceAll('-', '') }${ isoValue.slice(11, 13) }${ isoValue.slice(14, 16) }`;

// Append a numeric suffix (e.g. .2, .3) when rotating within the same hour due to the block-count limit.
const suffixSegment = suffix > 0 ? `.${ suffix + 1 }` : '';

// Construct the final blob name in the format YYYYMMDDHH.audit.log or YYYYMMDDHH.operational.log.
// Construct the final blob name in the format YYYYMMDDHHmm.audit.log or YYYYMMDDHHmm.operational.log.
const blobName = `${ datePrefix }.${ type }${ suffixSegment }.log`;

const collection = type === 'audit' ? this.#auditCollection : this.#operationalCollection;
Expand Down
9 changes: 7 additions & 2 deletions src/plugins/ConsoleDestination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,13 @@ export class ConsoleDestination extends LoggingPlugin {
* @param log The payload to log alongside the message.
*/
static #writeToConsole(method: ConsoleDestinationMethod, message: string, log?: AuditLog | OperationalLog): void {
// eslint-disable-next-line no-console
console[method](message, log);
if (log) {
// eslint-disable-next-line no-console
console[method](message, log);
} else {
// eslint-disable-next-line no-console
console[method](message);
}
}

#formatOperationalLog(log: OperationalLog): string {
Expand Down
1 change: 1 addition & 0 deletions src/test-app/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
This app is for long running tests to ensure plugins are robust over time
Loading
Loading