feat: retain application log history

This commit is contained in:
ajnart committed 2026-08-20 11:36:42 +02:00
1 parent a2219b72f6
commit ce61c87cd8
12 files changed
+396 -51

No files matched your search

+57 -7
View File
@@ -11,6 +11,10 @@ import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
const logger = createLogger({ module: "logRouter" });
type LogSubscriptionEvent =
| { type: "history"; messages: LoggerMessage[] }
| { type: "message"; message: LoggerMessage };
export const logRouter = createTRPCRouter({
subscribe: permissionRequiredProcedure
.requiresPermission("other-view-logs")
@@ -20,15 +24,61 @@ export const logRouter = createTRPCRouter({
}),
)
.subscription(({ input }) => {
return observable<LoggerMessage>((emit) => {
const unsubscribe = loggingChannel.subscribe((data) => {
if (!input.levels.includes(data.level)) return;
emit.next(data);
});
logger.info("A tRPC client has connected to the logging procedure");
return observable<LogSubscriptionEvent>((emit) => {
let isClosed = false;
let unsubscribe: (() => void) | null = null;
let isHistoryLoaded = false;
const bufferedMessages: LoggerMessage[] = [];
const onMessage = (message: LoggerMessage) => {
if (!input.levels.includes(message.level)) return;
if (!isHistoryLoaded) {
bufferedMessages.push(message);
return;
}
emit.next({ type: "message", message });
};
void loggingChannel
.subscribeAsync(onMessage)
.then(async (unsubscribeFromLogging) => {
if (isClosed) {
unsubscribeFromLogging();
return;
}
unsubscribe = unsubscribeFromLogging;
logger.info("A tRPC client has connected to the logging procedure");
const history = (await loggingChannel.getRecentAsync()).filter((message) =>
input.levels.includes(message.level),
);
if (isClosed) return;
emit.next({ type: "history", messages: history });
const historyIds = new Set(history.map((message) => message.id));
isHistoryLoaded = true;
for (const message of bufferedMessages) {
if (!historyIds.has(message.id)) {
emit.next({ type: "message", message });
}
}
bufferedMessages.length = 0;
})
.catch((error: unknown) => {
unsubscribe?.();
unsubscribe = null;
if (!isClosed) {
emit.error(error);
}
});
return () => {
unsubscribe();
isClosed = true;
unsubscribe?.();
};
});
}),
@@ -1,6 +1,17 @@
export const logLevels = ["error", "warn", "info", "debug"] as const;
export type LogLevel = (typeof logLevels)[number];
export interface LoggerMessage {
id: string;
timestamp: Date;
message: string;
level: LogLevel;
}
export const LOG_HISTORY_MAX_ENTRIES = 5_000;
export const LOG_HISTORY_KEY = "list:logging:history";
export const LOG_PUBLISH_CHANNEL = "pubSub:logging";
export const logLevelConfiguration = {
error: {
prefix: "🔴",
@@ -1,8 +1,11 @@
import superjson from "superjson";
import { randomUUID } from "node:crypto";
import { stringify } from "superjson";
import Transport from "winston-transport";
import type { RedisClient } from "../../redis/client";
import { createRedisClient } from "../../redis/client";
import type { LoggerMessage } from "../constants";
import { LOG_HISTORY_KEY, LOG_HISTORY_MAX_ENTRIES, LOG_PUBLISH_CHANNEL } from "../constants";
const messageSymbol = Symbol.for("message");
const levelSymbol = Symbol.for("level");
@@ -13,12 +16,11 @@ const levelSymbol = Symbol.for("level");
//
export class RedisTransport extends Transport {
private redis: RedisClient | null = null;
public static readonly publishChannel = "pubSub:logging";
/**
* Log the info to the Redis channel
* Retain the log in a bounded Redis list and publish it to live subscribers.
*/
log(info: { [messageSymbol]: string; [levelSymbol]: string }, callback: () => void) {
log(info: { [messageSymbol]: string; [levelSymbol]: LoggerMessage["level"] }, callback: () => void) {
setImmediate(() => {
this.emit("logged", info);
});
@@ -26,19 +28,23 @@ export class RedisTransport extends Transport {
// Is only initialized here because it did not work when initialized in the constructor or outside the class
this.redis ??= createRedisClient();
const message: LoggerMessage = {
id: randomUUID(),
timestamp: new Date(),
message: info[messageSymbol],
level: info[levelSymbol],
};
const serializedMessage = stringify(message);
this.redis
.publish(
RedisTransport.publishChannel,
superjson.stringify({
message: info[messageSymbol],
level: info[levelSymbol],
}),
)
.then(() => {
callback();
})
.multi()
.lpush(LOG_HISTORY_KEY, serializedMessage)
.ltrim(LOG_HISTORY_KEY, 0, LOG_HISTORY_MAX_ENTRIES - 1)
.publish(LOG_PUBLISH_CHANNEL, serializedMessage)
.exec()
.catch(() => {
// Ignore errors
});
})
.finally(callback);
}
}
+17 -6
View File
@@ -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 };
}
/**
+20 -4
View File
@@ -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.
+4
View File
@@ -8385,6 +8385,10 @@
}
},
"log": {
"context": {
"widgetError": "Widget reported an error around this time",
"expiredWidgetError": "Logs around this widget error are no longer retained"
},
"level": {
"option": {
"debug": "Debug",
+13 -2
View File
@@ -1,5 +1,7 @@
import { useState } from "react";
import { Anchor, Button, Stack, Text } from "@mantine/core";
import { useSession } from "@homarr/auth/client";
import type { stringOrTranslation } from "@homarr/translation";
import { translateIfNecessary } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
@@ -15,14 +17,23 @@ export interface BaseWidgetErrorProps {
export const BaseWidgetError = (props: BaseWidgetErrorProps) => {
const t = useI18n();
const { data: session } = useSession();
const [errorTimestamp] = useState(Date.now);
const canViewLogs = session?.user.permissions.includes("other-view-logs") ?? false;
return (
<Stack h="100%" align="center" justify="center" gap="md" data-homarr-widget-error>
<props.icon size={40} />
<Stack gap={0}>
<Text ta="center">{translateIfNecessary(t, props.message)}</Text>
{props.showLogsLink && (
<Anchor component={Link} href="/manage/tools/logs" target="_blank" ta="center" size="sm">
{props.showLogsLink && canViewLogs && (
<Anchor
component={Link}
href={`/manage/tools/logs?focus=${errorTimestamp}`}
target="_blank"
ta="center"
size="sm"
>
{t("common.action.checkLogs")}
</Anchor>
)}