feat: retain application log history
This commit is contained in:
1 parent
a2219b72f6
commit
ce61c87cd8
12 files changed
+396
-51
No files matched your search
@@ -1,6 +1,10 @@
|
||||
import type { LogLevel } from "@homarr/core/infrastructure/logs/constants";
|
||||
import { parse } from "superjson";
|
||||
|
||||
import type { LoggerMessage } from "@homarr/core/infrastructure/logs/constants";
|
||||
import { LOG_HISTORY_KEY, LOG_HISTORY_MAX_ENTRIES } from "@homarr/core/infrastructure/logs/constants";
|
||||
|
||||
import { createListChannel, createSubPubChannel } from "./lib/channel";
|
||||
import { createRedisConnection } from "./lib/connection";
|
||||
|
||||
export {
|
||||
handshakeAsync,
|
||||
@@ -15,9 +19,16 @@ export const pingChannel = createSubPubChannel<
|
||||
>("ping");
|
||||
export const pingUrlChannel = createListChannel<string>("ping-url");
|
||||
|
||||
export interface LoggerMessage {
|
||||
message: string;
|
||||
level: LogLevel;
|
||||
}
|
||||
export type { LoggerMessage } from "@homarr/core/infrastructure/logs/constants";
|
||||
|
||||
export const loggingChannel = createSubPubChannel<LoggerMessage>("logging");
|
||||
const liveLoggingChannel = createSubPubChannel<LoggerMessage>("logging", { persist: false });
|
||||
const loggingHistoryClient = createRedisConnection();
|
||||
|
||||
export const loggingChannel = {
|
||||
subscribe: liveLoggingChannel.subscribe,
|
||||
subscribeAsync: liveLoggingChannel.subscribeAsync,
|
||||
getRecentAsync: async () => {
|
||||
const messages = await loggingHistoryClient.lrange(LOG_HISTORY_KEY, 0, LOG_HISTORY_MAX_ENTRIES - 1);
|
||||
return messages.map((message) => parse<LoggerMessage>(message)).toReversed();
|
||||
},
|
||||
};
|
||||
@@ -18,6 +18,7 @@ type SubscriptionCallback = (message: string) => MaybePromise<void>;
|
||||
*/
|
||||
export class ChannelSubscriptionTracker {
|
||||
private static subscriptions = new Map<string, Map<string, SubscriptionCallback>>();
|
||||
private static subscriptionReadiness = new Map<string, Promise<void>>();
|
||||
private static redis = createRedisConnection();
|
||||
private static listenerActive = false;
|
||||
|
||||
@@ -28,6 +29,25 @@ export class ChannelSubscriptionTracker {
|
||||
* @returns a function to unsubscribe from the channel
|
||||
*/
|
||||
public static subscribe(channelName: string, callback: SubscriptionCallback) {
|
||||
return this.registerSubscription(channelName, callback).unsubscribe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to a channel and resolves once Redis confirms the subscription.
|
||||
*/
|
||||
public static async subscribeAsync(channelName: string, callback: SubscriptionCallback) {
|
||||
const subscription = this.registerSubscription(channelName, callback);
|
||||
|
||||
try {
|
||||
await subscription.ready;
|
||||
return subscription.unsubscribe;
|
||||
} catch (error) {
|
||||
subscription.unsubscribe();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private static registerSubscription(channelName: string, callback: SubscriptionCallback) {
|
||||
logger.debug("Adding redis channel callback", { channel: channelName });
|
||||
|
||||
// We only want to activate the listener once
|
||||
@@ -38,11 +58,14 @@ export class ChannelSubscriptionTracker {
|
||||
|
||||
const channelSubscriptions = this.subscriptions.get(channelName) ?? new Map<string, SubscriptionCallback>();
|
||||
const id = randomUUID();
|
||||
let ready = this.subscriptionReadiness.get(channelName) ?? Promise.resolve();
|
||||
|
||||
// If there are no subscriptions to the channel, subscribe to it
|
||||
if (channelSubscriptions.size === 0) {
|
||||
logger.debug("Subscribing to redis channel", { channel: channelName });
|
||||
void this.redis.subscribe(channelName);
|
||||
ready = this.redis.subscribe(channelName).then(() => undefined);
|
||||
this.subscriptionReadiness.set(channelName, ready);
|
||||
void ready.catch(() => undefined);
|
||||
}
|
||||
|
||||
logger.debug("Adding redis channel callback", { channel: channelName, id });
|
||||
@@ -50,24 +73,26 @@ export class ChannelSubscriptionTracker {
|
||||
|
||||
this.subscriptions.set(channelName, channelSubscriptions);
|
||||
|
||||
// Return a function to unsubscribe
|
||||
return () => {
|
||||
const unsubscribe = () => {
|
||||
logger.debug("Removing redis channel callback", { channel: channelName, id });
|
||||
|
||||
const channelSubscriptions = this.subscriptions.get(channelName);
|
||||
if (!channelSubscriptions) return;
|
||||
const currentSubscriptions = this.subscriptions.get(channelName);
|
||||
if (!currentSubscriptions) return;
|
||||
|
||||
channelSubscriptions.delete(id);
|
||||
currentSubscriptions.delete(id);
|
||||
|
||||
// If there are no subscriptions to the channel, unsubscribe from it
|
||||
if (channelSubscriptions.size >= 1) {
|
||||
if (currentSubscriptions.size >= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug("Unsubscribing from redis channel", { channel: channelName });
|
||||
void this.redis.unsubscribe(channelName);
|
||||
this.subscriptions.delete(channelName);
|
||||
this.subscriptionReadiness.delete(channelName);
|
||||
};
|
||||
|
||||
return { ready, unsubscribe };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import superjson from "superjson";
|
||||
import superjson, { parse } from "superjson";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
|
||||
@@ -8,6 +8,12 @@ import { createRedisConnection } from "./connection";
|
||||
const publisher = createRedisConnection();
|
||||
const lastDataClient = createRedisConnection();
|
||||
|
||||
const deserializeCallback =
|
||||
<TData>(callback: (data: TData) => void) =>
|
||||
(message: string) => {
|
||||
callback(parse(message));
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new pub/sub channel.
|
||||
* @param name name of the channel
|
||||
@@ -29,9 +35,19 @@ export const createSubPubChannel = <TData>(name: string, { persist }: { persist:
|
||||
}
|
||||
});
|
||||
}
|
||||
return ChannelSubscriptionTracker.subscribe(channelName, (message) => {
|
||||
callback(superjson.parse(message));
|
||||
});
|
||||
return ChannelSubscriptionTracker.subscribe(channelName, deserializeCallback(callback));
|
||||
},
|
||||
/**
|
||||
* Subscribes to the channel and resolves once Redis confirms the subscription.
|
||||
*/
|
||||
subscribeAsync: async (callback: (data: TData) => void) => {
|
||||
if (persist) {
|
||||
const data = await lastDataClient.get(lastChannelName);
|
||||
if (data) {
|
||||
callback(superjson.parse(data));
|
||||
}
|
||||
}
|
||||
return await ChannelSubscriptionTracker.subscribeAsync(channelName, deserializeCallback(callback));
|
||||
},
|
||||
/**
|
||||
* Publish data to the channel with last data saved.
|
||||
|
||||
Reference in new issue
Block a user