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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user