diff --git a/apps/landing/public/screenshots/guides/remote-server-monitoring-01-hosts.webp b/apps/landing/public/screenshots/guides/remote-server-monitoring-01-hosts.webp new file mode 100644 index 0000000000..b6f3a2cf47 Binary files /dev/null and b/apps/landing/public/screenshots/guides/remote-server-monitoring-01-hosts.webp differ diff --git a/apps/landing/public/screenshots/guides/remote-server-monitoring-02-host-detail.webp b/apps/landing/public/screenshots/guides/remote-server-monitoring-02-host-detail.webp new file mode 100644 index 0000000000..f5eab51eac Binary files /dev/null and b/apps/landing/public/screenshots/guides/remote-server-monitoring-02-host-detail.webp differ diff --git a/apps/landing/public/screenshots/guides/remote-server-monitoring-03-heartbeat-rule.webp b/apps/landing/public/screenshots/guides/remote-server-monitoring-03-heartbeat-rule.webp new file mode 100644 index 0000000000..9c4168f516 Binary files /dev/null and b/apps/landing/public/screenshots/guides/remote-server-monitoring-03-heartbeat-rule.webp differ diff --git a/apps/landing/src/lib/guide-highlight.ts b/apps/landing/src/lib/guide-highlight.ts index 57b4497063..00f29016ca 100644 --- a/apps/landing/src/lib/guide-highlight.ts +++ b/apps/landing/src/lib/guide-highlight.ts @@ -20,6 +20,7 @@ export type GuideLang = | "python-logging" | "loguru-output" | "otel-debug" + | "sql" | "powershell"; const escape = (s: string) => s.replace(/&/g, "&").replace(//g, ">"); @@ -93,7 +94,8 @@ const yaml = (code: string) => [/#.*/y, (m) => span("comment", m[0])], [/(?<=^|\n)(\s*-?\s*)([\w./-]+)(:)/y, (m) => escape(m[1]) + span("name", m[2]) + escape(m[3])], [/"[^"\n]*"/y, (m) => span("labels", m[0])], - [/\b\d+[a-z]*\b/y, (m) => span("value", m[0])], + [/(? span("value", m[0])], + [/[\w.-]+/y, (m) => escape(m[0])], ]); const python = (code: string) => @@ -182,6 +184,20 @@ const pythonLogging = (code: string) => [/[a-zA-Z_]\w*/y, (m) => escape(m[0])], ]); +// ClickHouse SQL: comments, strings, $__ macros, function calls, column names. +// Keywords stay plain so the columns and functions stand out. +const sql = (code: string) => + tokenize(code, [ + [/--.*/y, (m) => span("comment", m[0])], + [/'[^'\n]*'/y, (m) => span("labels", m[0])], + [/\$__\w+/y, (m) => span("value", m[0])], + [/\b(?:SELECT|FROM|WHERE|AND|OR|NOT|IN|AS|GROUP|BY|ORDER|LIMIT|WITH)\b/y, (m) => escape(m[0])], + [/[a-zA-Z_]\w*(?=\()/y, (m) => span("fn", m[0])], + [/\b[A-Z][A-Za-z]+\b/y, (m) => span("name", m[0])], + [/\b\d+(?:\.\d+)?\b/y, (m) => span("value", m[0])], + [/[a-zA-Z_]\w*/y, (m) => escape(m[0])], + ]); + // Loguru's default console line: time | LEVEL | location - message. Each level // gets its own class (tok-level-info, tok-level-error...); traceback lines pass through. const loguruOutput = (code: string) => @@ -265,5 +281,6 @@ export const highlight = (code: string, lang: GuideLang) => "python-logging": pythonLogging, "loguru-output": loguruOutput, "otel-debug": otelDebug, + sql, powershell, })[lang](code); diff --git a/apps/landing/src/pages/guides/index.astro b/apps/landing/src/pages/guides/index.astro index 959e92ea71..73b7b200d0 100644 --- a/apps/landing/src/pages/guides/index.astro +++ b/apps/landing/src/pages/guides/index.astro @@ -91,6 +91,13 @@ const coreGuides = [ description: "Start logging with Loguru, set the level, write files and JSON, add context, then send its logs to OpenTelemetry.", read: "7 min", }, + { + href: "/guides/remote-server-monitoring", + label: "Infrastructure", + title: "Remote server monitoring with OpenTelemetry", + description: "Agent vs agentless and the ports each needs, a Collector setup with host metrics, logs, HTTP and SSH checks, and alerts for servers that go quiet.", + read: "10 min", + }, ]; const investigationGuides = [ diff --git a/apps/landing/src/pages/guides/remote-server-monitoring.astro b/apps/landing/src/pages/guides/remote-server-monitoring.astro new file mode 100644 index 0000000000..24501ac437 --- /dev/null +++ b/apps/landing/src/pages/guides/remote-server-monitoring.astro @@ -0,0 +1,795 @@ +--- +import Layout from "../../layouts/Layout.astro"; +import Nav from "../../components/Nav.astro"; +import Footer from "../../components/Footer.astro"; +import BottomCTA from "../../components/BottomCTA.astro"; +import Breadcrumb from "../../components/Breadcrumb.astro"; +import FAQ from "../../components/FAQ.astro"; +import SeoHead from "../../components/SeoHead.astro"; +import TableOfContents from "../../components/TableOfContents.astro"; +import { CONTRIBUTORS } from "../../lib/changelog-meta"; +import { highlight } from "../../lib/guide-highlight"; +import LanguageTabs from "../../components/docs/LanguageTabs.astro"; +import LanguageTab from "../../components/docs/LanguageTab.astro"; + +const pageTitle = "Remote server monitoring: agents, signals, alerts | Maple"; +const pageDescription = + "Remote server monitoring with OpenTelemetry: agent vs agentless collection, the ports each needs, what to watch, and alerts for servers that go quiet."; +const published = "2026-10-19"; +const updatedLabel = "November 1, 2026"; +const author = CONTRIBUTORS.jeremyfunk; +const authorUrl = `https://github.com/${author.github}`; + +const toc = [ + { depth: 2, slug: "definition", text: "Definition" }, + { depth: 2, slug: "methods", text: "Agent vs agentless" }, + { depth: 3, slug: "agent", text: "Agent: the server pushes" }, + { depth: 3, slug: "agentless", text: "Agentless: a poller pulls" }, + { depth: 3, slug: "tools", text: "Tools" }, + { depth: 3, slug: "opentelemetry", text: "Why OpenTelemetry" }, + { depth: 2, slug: "signals", text: "Signals to watch" }, + { depth: 3, slug: "ssh", text: "Checks over SSH" }, + { depth: 2, slug: "setup", text: "Collector setup" }, + { depth: 3, slug: "agent-config", text: "Agent config" }, + { depth: 3, slug: "fleet", text: "Many servers" }, + { depth: 2, slug: "maple", text: "Hosts in Maple" }, + { depth: 2, slug: "alerts", text: "Alerts" }, + { depth: 3, slug: "heartbeat", text: "Heartbeat per server" }, + { depth: 3, slug: "fleet-heartbeat", text: "One rule for the fleet" }, + { depth: 3, slug: "thresholds", text: "Disk, memory, CPU" }, +]; +const sectionNav = toc.filter((h) => h.depth === 2); + +// One color per way of collecting, reused by the diagram, badges, tables and configs. +const kinds = { + agent: { color: "var(--chart-3)" }, + poller: { color: "var(--chart-2)" }, +} as const; +type Kind = keyof typeof kinds; + +const methods: { method: string; kind: Kind; opens: string; port: string; fits: string }[] = [ + { method: "OpenTelemetry Collector, or another agent", kind: "agent", opens: "The server, outbound", port: "Nothing", fits: "Linux and Windows servers you run" }, + { method: "SSH polling", kind: "poller", opens: "The poller", port: "22, plus an account", fits: "A few servers, one-off checks" }, + { method: "SNMP", kind: "poller", opens: "The poller", port: "UDP 161", fits: "Switches, firewalls, UPS units" }, + { method: "WMI or WinRM", kind: "poller", opens: "The poller", port: "135 and dynamic RPC, or 5985/5986", fits: "Windows servers in a domain" }, + { method: "Prometheus scrape of node_exporter", kind: "poller", opens: "The scraper", port: "9100", fits: "Fleets already on Prometheus" }, +]; + +const signals: { signal: string; metric: string; alert: string }[] = [ + { signal: "Heartbeat", metric: "system.memory.utilization", alert: "No new points from the host for 5 minutes" }, + { signal: "CPU", metric: "system.cpu.utilization, system.cpu.load_average.15m", alert: "Above 90% for 15 minutes, or load above the core count" }, + { signal: "Memory", metric: "system.memory.utilization", alert: "Above 90% used, or OOM kills in the logs" }, + { signal: "Disk space", metric: "system.filesystem.utilization", alert: "Any writable mount above 90%" }, + { signal: "Network", metric: "system.network.errors, system.network.dropped", alert: "Errors or drops climbing" }, + { signal: "Processes", metric: "system.processes.count", alert: "Blocked processes piling up" }, + { signal: "Logs", metric: "file_log", alert: "Disk I/O errors, OOM kills, SSH auth failures" }, +]; + +const sshTabs: { id: string; label: string; code: string; read: string }[] = [ + { + id: "cpu", + label: "CPU", + code: `uptime # load average over 1, 5 and 15 minutes +top -o %CPU # live, busiest process first (htop if installed) +vmstat 5 5 # five samples, 5 seconds apart`, + read: "Load above the number of cores means work is queueing. In vmstat, a high r column is the same queue, and a high wa means the CPU is waiting on disk rather than busy.", + }, + { + id: "memory", + label: "Memory", + code: `free -h # used, available, swap +ps aux --sort=-%mem | head -5 # the biggest processes +vmstat 5 5 # si and so: swapping in and out`, + read: "Read available, not free: the kernel fills spare memory with cache and hands it back on demand. Non-zero si and so in vmstat mean the server is swapping.", + }, + { + id: "disk", + label: "Disk", + code: `df -h # space per mount +df -i # inodes per mount +du -xh /var --max-depth=1 | sort -h | tail # what filled /var +iostat -xz 5 3 # per-device load (sysstat package)`, + read: "A device near 100 in %util with rising r_await or w_await is saturated. iostat comes with the sysstat package.", + }, + { + id: "network", + label: "Network", + code: `ss -tulpn # listening ports and the process behind each +ss -s # connection counts by state +ip -s link # bytes, errors and drops per interface`, + read: "Thousands of connections in timewait point at a client opening a new connection per request. Errors or drops that keep rising in ip -s link point at the link or the driver.", + }, + { + id: "logs", + label: "Logs", + code: `journalctl -p err -b # errors since the last boot +journalctl -k -p err --since today # kernel errors: disks, OOM kills +journalctl -u nginx --since "1 hour ago" # one service`, + read: "Most distributions log through the journal. On systems with rsyslog, the same lines are in /var/log/syslog or /var/log/messages.", + }, +]; + +const sshLoop = `for h in web-01 web-02 db-01; do + ssh "$h" 'echo "== $(hostname)"; uptime; df -h / | tail -1' +done`; + +const installCommands = `# Debian and Ubuntu +curl -LO https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download/v0.163.1/otelcol-contrib_0.163.1_linux_amd64.deb +sudo dpkg -i otelcol-contrib_0.163.1_linux_amd64.deb + +# RHEL, Rocky, Alma, Fedora +curl -LO https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download/v0.163.1/otelcol-contrib_0.163.1_linux_amd64.rpm +sudo rpm -ivh otelcol-contrib_0.163.1_linux_amd64.rpm`; + +const agentConfig = `receivers: + host_metrics: + collection_interval: 30s + scrapers: + cpu: + metrics: + system.cpu.utilization: + enabled: true + load: {} + memory: + metrics: + system.memory.utilization: + enabled: true + filesystem: + metrics: + system.filesystem.utilization: + enabled: true + network: {} + processes: {} + file_log: + include: + - /var/log/syslog + - /var/log/auth.log + start_at: end + +processors: + resource_detection: + detectors: [env, system] + resource/logs: + attributes: + - key: service.name + value: system-logs + action: upsert + batch: {} + +exporters: + otlp_http/maple: + endpoint: https://ingest.maple.dev + compression: gzip + headers: + x-maple-ingest-key: \${env:MAPLE_INGEST_KEY} + +service: + pipelines: + metrics: + receivers: [host_metrics] + processors: [resource_detection, batch] + exporters: [otlp_http/maple] + logs: + receivers: [file_log] + processors: [resource_detection, resource/logs, batch] + exporters: [otlp_http/maple]`; + +const envFile = `OTELCOL_OPTIONS="--config=/etc/otelcol-contrib/config.yaml" +MAPLE_INGEST_KEY=maple_sk_... +OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=production`; + +const serviceCommands = `sudo chmod 600 /etc/otelcol-contrib/otelcol-contrib.conf +sudo usermod -aG adm otelcol-contrib # read access to /var/log +sudo systemctl enable otelcol-contrib +sudo systemctl restart otelcol-contrib +journalctl -u otelcol-contrib -f`; + +const envTemplate = `OTELCOL_OPTIONS="--config=/etc/otelcol-contrib/config.yaml" +MAPLE_INGEST_KEY={{ maple_ingest_key }} +OTEL_RESOURCE_ATTRIBUTES=host.name={{ inventory_hostname }},deployment.environment.name={{ env_name }}`; + +const heartbeatSql = `SELECT $__timeGroup(TimeUnix) AS bucket, count() AS value +FROM metrics_gauge +WHERE $__orgFilter AND $__timeFilter(TimeUnix) + AND MetricName = 'system.memory.utilization' + AND ResourceAttributes['host.name'] = 'web-01' +GROUP BY bucket`; + +const fleetSql = `SELECT $__timeGroup(TimeUnix) AS bucket, + uniqExact(ResourceAttributes['host.name']) AS value +FROM metrics_gauge +WHERE $__orgFilter AND $__timeFilter(TimeUnix) + AND MetricName = 'system.memory.utilization' +GROUP BY bucket`; + +const thresholdRules: { id: string; label: string; sql: string; settings: string }[] = [ + { + id: "rule-disk", + label: "Disk", + sql: `SELECT $__timeGroup(TimeUnix) AS bucket, + ResourceAttributes['host.name'] AS group, + max(Value) AS value +FROM metrics_gauge +WHERE $__orgFilter AND $__timeFilter(TimeUnix) + AND MetricName = 'system.filesystem.utilization' + AND Attributes['mode'] = 'rw' +GROUP BY bucket, group`, + settings: "Window 5 min, Alert when the value is above, Threshold 0.9. The fullest writable mount on each host. mode = 'rw' drops read-only mounts such as snap images, which always read 100%.", + }, + { + id: "rule-memory", + label: "Memory", + sql: `SELECT $__timeGroup(TimeUnix) AS bucket, + ResourceAttributes['host.name'] AS group, + max(Value) AS value +FROM metrics_gauge +WHERE $__orgFilter AND $__timeFilter(TimeUnix) + AND MetricName = 'system.memory.utilization' + AND Attributes['state'] = 'used' +GROUP BY bucket, group`, + settings: "Window 5 min, Alert when the value is above, Threshold 0.9. The metric has one point per state (used, free, cached, buffered and slab), so filter to used.", + }, + { + id: "rule-cpu", + label: "CPU", + sql: `SELECT $__timeGroup(TimeUnix) AS bucket, + ResourceAttributes['host.name'] AS group, + 1 - avg(Value) AS value +FROM metrics_gauge +WHERE $__orgFilter AND $__timeFilter(TimeUnix) + AND MetricName = 'system.cpu.utilization' + AND Attributes['state'] = 'idle' +GROUP BY bucket, group`, + settings: "Window 15 min, Alert when the value is above, Threshold 0.9. Busy time is one minus idle time. The longer window keeps short spikes, such as a nightly backup, from paging.", + }, +]; + +const faqs = [ + { + question: "What is remote server monitoring?", + answer: + "Remote server monitoring is collecting health data from servers you cannot physically reach, such as cloud VMs, machines in a colocation rack or a server in a branch office, and alerting when something goes wrong. It covers CPU, memory, disk, network, processes and logs, collected by an agent on each server or by a central system that polls them, plus an alert for a server that stops reporting.", + }, + { + question: "What is the difference between agent-based and agentless monitoring?", + answer: + "An agent is a program on each server that reads local metrics and logs and sends them out over an outbound connection. Agentless monitoring has a central poller connect in over SSH, SNMP, WMI or a Prometheus scrape. Agents see more, including logs and per-mount disk usage, and need no inbound port. Agentless methods suit devices that cannot run an agent, but every target needs an open port and the poller needs credentials for each one.", + }, + { + question: "How can I monitor a remote Linux server for free?", + answer: + "Install the OpenTelemetry Collector Contrib, which is open source under the Apache 2.0 license, as a systemd service with the host_metrics and file_log receivers, and send the data to any OTLP backend. Prometheus with node_exporter and Grafana is a self-hosted alternative. For a one-off look, SSH in and run uptime, vmstat, free -h, df -h and journalctl.", + }, + { + question: "How do I get alerted when a remote server goes down?", + answer: + "Alert on missing data. A heartbeat alert fires when the server's agent has sent nothing for a few minutes, which catches a crashed host, a broken network path and a stopped agent. Threshold alerts on disk, memory and CPU catch a server that still reports but is running out of room.", + }, + { + question: "Which remote server monitoring tools are there?", + answer: + "Agents include the OpenTelemetry Collector, Prometheus node_exporter, and the agents of Zabbix, Datadog and New Relic. Agentless tools poll over SNMP, SSH or WMI, as Zabbix, Nagios-based systems and PRTG can. For servers you run, an agent on each one covers more than polling, which is best kept for devices that can't run an agent.", + }, +]; + +const siteUrl = Astro.site?.toString().replace(/\/$/, "") ?? "https://maple.dev"; +const articleJsonLd = { + "@context": "https://schema.org", + "@type": "TechArticle", + headline: "Remote server monitoring with OpenTelemetry", + description: pageDescription, + datePublished: published, + dateModified: "2026-11-01", + author: { "@type": "Person", name: author.name, url: authorUrl }, + publisher: { + "@type": "Organization", + name: "Maple", + logo: { "@type": "ImageObject", url: `${siteUrl}/logo192.png` }, + }, + mainEntityOfPage: `${siteUrl}/guides/remote-server-monitoring`, +}; +--- + + + +