From 3e06cd39b10bb350eb1bb6a136ad34e525dff7f6 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 2 Sep 2026 11:09:38 -0700 Subject: [PATCH] add docker logs to provider --- server/src/service/network/DockerProvider.ts | 87 +++++++++++++++++++- 1 file changed, 85 insertions(+), 2 deletions(-) diff --git a/server/src/service/network/DockerProvider.ts b/server/src/service/network/DockerProvider.ts index 7b6c98e7c..4e0d5e921 100644 --- a/server/src/service/network/DockerProvider.ts +++ b/server/src/service/network/DockerProvider.ts @@ -2,7 +2,9 @@ import { IStatusProvider } from "@/service/network/IStatusProvider.js"; import { DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js"; import { + DOCKER_LOG_TAIL_LINES, DockerContainerInfo, + DockerContainerLogs, DockerContainerMount, DockerContainerPort, DockerContainerState, @@ -10,6 +12,8 @@ import { DockerContainerSummary, DockerHealthStatus, DockerHealthStatuses, + DockerLogLine, + DockerLogStream, DockerPortProtocol, DockerPortProtocols, } from "@/domain/docker/docker.type.js"; @@ -23,6 +27,9 @@ type DockerodeType = typeof Dockerode; const SERVICE_NAME = "DockerProvider"; const STATS_CONCURRENCY = 5; +const DOCKER_LOG_MAX_LINE_BYTES = 4096; +const DOCKER_LOG_TRUNCATION_MARKER = " …[truncated]"; +const DOCKER_LOG_TS_REGEX = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?Z /; export interface DockerError extends Error { statusCode?: number; @@ -119,10 +126,14 @@ export class DockerProvider implements IStatusProvider { }; } + private toContainerName = (summary: Dockerode.ContainerInfo): string => { + return summary.Names?.[0]?.replace(/^\//, "") ?? summary.Id.slice(0, 12); + }; + private async toContainerInfo(docker: Dockerode, summary: Dockerode.ContainerInfo): Promise { const info: DockerContainerInfo = { id: summary.Id, - name: summary.Names?.[0]?.replace(/^\//, "") ?? summary.Id.slice(0, 12), + name: this.toContainerName(summary), image: summary.Image, state: this.toContainerState(summary.State), status: summary.Status, @@ -198,6 +209,76 @@ export class DockerProvider implements IStatusProvider { })); } + // Log parsing + private isMultiplexed(buffer: Buffer): boolean { + return buffer.length >= 8 && buffer.readUInt8(0) <= 2 && buffer.readUInt8(1) === 0 && buffer.readUInt8(2) === 0 && buffer.readUInt8(3) === 0; + } + + // Docker will will merge stdout and stderr if stared with `-t`. + // Most containers will _not_ be run with `-t` though, in which case logs come in frames of [header][payload] + private demuxLogBuffer = (buffer: Buffer): { stream: DockerLogStream; chunk: string }[] => { + if (!this.isMultiplexed(buffer)) return [{ stream: "stdout", chunk: buffer.toString("utf8") }]; // `-t` case, log is just a string, return it + const frames: { stream: DockerLogStream; chunk: string }[] = []; + let offset = 0; // Start reading at the first byte of the first header + while (offset + 8 <= buffer.length) { + // Every 8 bytes is a header, if < 8 bytes at the end, invalid frame, stop + const streamType = buffer.readUint8(offset); // First byte of the header is the stream type: 0 = stdin, 1 = stdout, 2 = stderr + const payloadSize = buffer.readUint32BE(offset + 4); // Byte 4 to 7 describe how many payload bytes follow the header + const payloadStart = offset + 8; // Advance to the payload (header 8 bytes long) + const payloadEnd = Math.min(payloadStart + payloadSize, buffer.length); // End is the start of the payload + payload size. + frames.push({ stream: streamType === 2 ? "stderr" : "stdout", chunk: buffer.toString("utf-8", payloadStart, payloadEnd) }); + offset = payloadEnd; + } + return frames; + }; + + private truncateLog = (text: string): string => { + if (Buffer.byteLength(text, "utf8") <= DOCKER_LOG_MAX_LINE_BYTES) return text; + const cut = Buffer.from(text, "utf8").subarray(0, DOCKER_LOG_MAX_LINE_BYTES).toString("utf8").replace(/�$/, ""); + return cut + DOCKER_LOG_TRUNCATION_MARKER; + }; + + // Docker drops trailing zeroes form timestamps. Pad out timestamps for correct string comparison + private toLogLine(stream: DockerLogStream, raw: string): DockerLogLine | null { + const match = raw.match(DOCKER_LOG_TS_REGEX); + if (!match) return null; // If there's no time stamp, something is wrong with this line, drop it + const ts = `${match[1]}.${(match[2] ?? "").padEnd(9, "0")}Z`; + const text = raw.slice(match[0].length).replace(/\r$/, ""); + return { ts, stream, text: this.truncateLog(text) }; + } + + private parseLogBuffer = (buffer: Buffer): DockerLogLine[] => { + const lines: DockerLogLine[] = []; + const demuxedBuffer = this.demuxLogBuffer(buffer); + for (const byteStream of demuxedBuffer) { + const { stream, chunk } = byteStream; + for (const raw of chunk.split("/n")) { + if (raw.length === 0) continue; + const line = this.toLogLine(stream, raw); + if (line) lines.push(line); + } + } + return lines; + }; + + private toContainerLogs = async (docker: Dockerode, summary: Dockerode.ContainerInfo): Promise => { + const containerName = this.toContainerName(summary); + try { + const buffer = await docker + .getContainer(summary.Id) + .logs({ follow: false, stdout: true, stderr: true, timestamps: true, tail: DOCKER_LOG_TAIL_LINES }); + return { containerId: summary.Id, containerName, lines: this.parseLogBuffer(buffer) }; + } catch (error: unknown) { + this.logger.warn({ + message: `Failed to read logs for container ${containerName}`, + service: SERVICE_NAME, + method: "toContainerLogs", + details: { containerId: summary.Id, error: error instanceof Error ? error.message : String(error) }, + }); + return null; + } + }; + handle = async (monitor: Monitor): Promise> => { try { const docker = new this.DockerLib(this.toDockerOptions(monitor)); @@ -226,6 +307,8 @@ export class DockerProvider implements IStatusProvider { const summaries = await docker.listContainers({ all: true }); const containers = await this.mapWithConcurrency(summaries, STATS_CONCURRENCY, (s) => this.toContainerInfo(docker, s)); + const rawLogs = await this.mapWithConcurrency(summaries, STATS_CONCURRENCY, (s) => this.toContainerLogs(docker, s)); + const logs = rawLogs.filter((log) => log !== null); const summary: DockerContainerSummary = { total: containers.length, running: containers.filter((c) => c.state === "running").length, @@ -240,7 +323,7 @@ export class DockerProvider implements IStatusProvider { code: 200, message: "Docker host is reachable", responseTime, - payload: { containers, summary }, + payload: { containers, summary, logs }, }; } catch (error: unknown) { if (error instanceof AppError) throw error;