add docker logs to provider

This commit is contained in:
Alex Holliday committed 2026-09-02 11:09:38 -07:00
1 parent efcc7829d4
commit 3e06cd39b1
1 file changed
+85 -2
+85 -2
View File
@@ -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<DockerStatusPayload> {
};
}
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<DockerContainerInfo> {
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<DockerStatusPayload> {
}));
}
// 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<DockerContainerLogs | null> => {
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<MonitorStatusResponse<DockerStatusPayload>> => {
try {
const docker = new this.DockerLib(this.toDockerOptions(monitor));
@@ -226,6 +307,8 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
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<DockerStatusPayload> {
code: 200,
message: "Docker host is reachable",
responseTime,
payload: { containers, summary },
payload: { containers, summary, logs },
};
} catch (error: unknown) {
if (error instanceof AppError) throw error;