diff --git a/apps/landing/public/screenshots/guides/windows-server-monitoring-02-service-status.webp b/apps/landing/public/screenshots/guides/windows-server-monitoring-02-service-status.webp new file mode 100644 index 0000000000..bebe8e355f Binary files /dev/null and b/apps/landing/public/screenshots/guides/windows-server-monitoring-02-service-status.webp differ diff --git a/apps/landing/public/screenshots/guides/windows-server-monitoring-03-event-logs.webp b/apps/landing/public/screenshots/guides/windows-server-monitoring-03-event-logs.webp new file mode 100644 index 0000000000..afec1951cd Binary files /dev/null and b/apps/landing/public/screenshots/guides/windows-server-monitoring-03-event-logs.webp differ diff --git a/apps/landing/src/lib/guide-highlight.ts b/apps/landing/src/lib/guide-highlight.ts index 8c6d9fc741..57b4497063 100644 --- a/apps/landing/src/lib/guide-highlight.ts +++ b/apps/landing/src/lib/guide-highlight.ts @@ -19,7 +19,8 @@ export type GuideLang = | "shell" | "python-logging" | "loguru-output" - | "otel-debug"; + | "otel-debug" + | "powershell"; const escape = (s: string) => s.replace(/&/g, "&").replace(//g, ">"); const span = (cls: string, text: string) => `${escape(text)}`; @@ -232,6 +233,18 @@ const otelDebug = (code: string) => }) .join("\n"); +// PowerShell: cmdlets as functions, quoted strings as labels, variables as +// names. Backtick escapes stay inside their string. +const powershell = (code: string) => + tokenize(code, [ + [/#.*/y, (m) => span("comment", m[0])], + [/'[^'\n]*'|"(?:[^"`\n]|`.)*"/y, (m) => span("labels", m[0])], + [/\$(?:env:)?\w+/y, (m) => span("name", m[0])], + [/\b[A-Z][a-z]+-[A-Z][A-Za-z]+\b/y, (m) => span("fn", m[0])], + [/\b\d+(?:\.\d+)?\b/y, (m) => span("value", m[0])], + [/[-\w]+/y, (m) => escape(m[0])], + ]); + export const highlight = (code: string, lang: GuideLang) => ({ exposition, @@ -252,4 +265,5 @@ export const highlight = (code: string, lang: GuideLang) => "python-logging": pythonLogging, "loguru-output": loguruOutput, "otel-debug": otelDebug, + powershell, })[lang](code); diff --git a/apps/landing/src/pages/guides/_windows-server-monitoring/config.yaml b/apps/landing/src/pages/guides/_windows-server-monitoring/config.yaml new file mode 100644 index 0000000000..57cc8abe67 --- /dev/null +++ b/apps/landing/src/pages/guides/_windows-server-monitoring/config.yaml @@ -0,0 +1,76 @@ +extensions: + file_storage: + directory: C:\ProgramData\OpenTelemetry Collector\storage + create_directory: true + +receivers: + host_metrics: + collection_interval: 30s + scrapers: + cpu: + metrics: + system.cpu.utilization: + enabled: true + memory: + metrics: + system.memory.utilization: + enabled: true + filesystem: + exclude_fs_types: + fs_types: [CDFS, UDF] # CD and DVD drives always read 100% full + match_type: strict + metrics: + system.filesystem.utilization: + enabled: true + load: {} + network: {} + + windows_service: + include_services: [W3SVC, MSSQLSERVER] # the services this server must keep running + + windows_event_log/errors: + storage: file_storage + query: | + + + + + + + resource: + service.name: windows-eventlog + + windows_event_log/security: + storage: file_storage + query: | + + + + + + resource: + service.name: windows-security + +processors: + resource_detection: + detectors: [system] + batch: {} + +exporters: + otlp_http/maple: + endpoint: https://ingest.maple.dev + compression: gzip + headers: + x-maple-ingest-key: ${env:MAPLE_INGEST_KEY} + +service: + extensions: [file_storage] + pipelines: + metrics: + receivers: [host_metrics, windows_service] + processors: [resource_detection, batch] + exporters: [otlp_http/maple] + logs: + receivers: [windows_event_log/errors, windows_event_log/security] + processors: [resource_detection, batch] + exporters: [otlp_http/maple] diff --git a/apps/landing/src/pages/guides/_windows-server-monitoring/install.ps1 b/apps/landing/src/pages/guides/_windows-server-monitoring/install.ps1 new file mode 100644 index 0000000000..234a54ed04 --- /dev/null +++ b/apps/landing/src/pages/guides/_windows-server-monitoring/install.ps1 @@ -0,0 +1,5 @@ +$v = '0.164.0' +$msi = "$env:TEMP\otelcol-contrib.msi" +Invoke-WebRequest -OutFile $msi -Uri "https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download/v$v/otelcol-contrib_${v}_windows_x64.msi" +Start-Process msiexec.exe -ArgumentList "/i `"$msi`" /qn" -Wait +Get-Service otelcol-contrib diff --git a/apps/landing/src/pages/guides/_windows-server-monitoring/start.ps1 b/apps/landing/src/pages/guides/_windows-server-monitoring/start.ps1 new file mode 100644 index 0000000000..647549735d --- /dev/null +++ b/apps/landing/src/pages/guides/_windows-server-monitoring/start.ps1 @@ -0,0 +1,6 @@ +$dir = 'C:\Program Files\OpenTelemetry Collector' +New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\otelcol-contrib' ` + -Name Environment -PropertyType MultiString -Value 'MAPLE_INGEST_KEY=YOUR_INGEST_KEY' -Force + +& "$dir\otelcol-contrib.exe" validate --config "$dir\config.yaml" +Restart-Service otelcol-contrib diff --git a/apps/landing/src/pages/guides/index.astro b/apps/landing/src/pages/guides/index.astro index d372be26dd..959e92ea71 100644 --- a/apps/landing/src/pages/guides/index.astro +++ b/apps/landing/src/pages/guides/index.astro @@ -122,6 +122,13 @@ const investigationGuides = [ description: "How a raw log line becomes named fields, with a tested OpenTelemetry Collector config for nginx, JSON, logfmt, multiline and syslog logs.", read: "9 min", }, + { + href: "/guides/windows-server-monitoring", + label: "Infrastructure", + title: "Windows server monitoring tools and what to watch", + description: "The signals worth an alert, the built-in, open-source and commercial tools compared, and an OpenTelemetry setup with alerts for stopped services and full disks.", + read: "9 min", + }, ]; const fieldNotes = (await getCollection("blog") as BlogPost[]) diff --git a/apps/landing/src/pages/guides/windows-server-monitoring.astro b/apps/landing/src/pages/guides/windows-server-monitoring.astro new file mode 100644 index 0000000000..2f4de5c0d1 --- /dev/null +++ b/apps/landing/src/pages/guides/windows-server-monitoring.astro @@ -0,0 +1,656 @@ +--- +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 collectorConfig from "./_windows-server-monitoring/config.yaml?raw"; +import installScript from "./_windows-server-monitoring/install.ps1?raw"; +import startScript from "./_windows-server-monitoring/start.ps1?raw"; + +const pageTitle = "Windows server monitoring tools and what to watch | Maple"; +const pageDescription = + "Compare Windows server monitoring tools, from Performance Monitor to Zabbix and OpenTelemetry, and set up alerts for stopped services, full disks and errors."; +const published = "2026-10-19"; +const updatedLabel = "October 31, 2026"; +const author = CONTRIBUTORS.jeremyfunk; +const authorUrl = `https://github.com/${author.github}`; + +const toc = [ + { depth: 2, slug: "overview", text: "Monitoring a Windows server" }, + { depth: 2, slug: "signals", text: "Signals to watch" }, + { depth: 2, slug: "tools", text: "Tools compared" }, + { depth: 3, slug: "picking", text: "Picking one" }, + { depth: 2, slug: "setup", text: "Set it up" }, + { depth: 3, slug: "install", text: "Install the Collector" }, + { depth: 3, slug: "config", text: "Write the config" }, + { depth: 3, slug: "start", text: "Add the key and start" }, + { depth: 2, slug: "alerts", text: "Alerts to set up first" }, +]; +const sectionNav = toc.filter((h) => h.depth === 2); + +// One color per signal. The same color marks the signal's card, the receiver +// that collects it in the config, and the alert that watches it. +const signals = [ + { + id: "memory", + name: "Memory", + color: "var(--chart-5)", + why: "When memory runs out, Windows moves pages to disk and every request slows down.", + alert: "Above 90% for 15 minutes", + }, + { + id: "disk", + name: "Disk space", + color: "var(--chart-3)", + why: "A full C: drive stops databases from writing and breaks Windows Update. It also fills slowly enough to fix ahead of time.", + alert: "Any drive above 85% full, critical at 95%", + }, + { + id: "cpu", + name: "CPU", + color: "var(--chart-2)", + why: "Short spikes are normal. CPU pinned for a quarter of an hour means a runaway process or a server that is too small.", + alert: "Above 90% for 15 minutes", + }, + { + id: "services", + name: "Services", + color: "oklch(0.8 0.14 95)", + why: "A stopped IIS or SQL Server service is an outage, even while CPU and memory look fine.", + alert: "A service you depend on is not running for 5 minutes", + }, + { + id: "events", + name: "Event Log errors", + color: "var(--chart-error)", + why: "Crashed applications and failing services write Error events to the System and Application logs, often before anyone notices.", + alert: "More than 10 Error events in 15 minutes, tuned to your normal", + }, + { + id: "logins", + name: "Failed logins", + color: "oklch(0.72 0.15 350)", + why: "A burst of failed logins, Security event 4625, usually means someone is guessing passwords over RDP.", + alert: "More than 20 failed logins in 5 minutes", + }, +] as const; +type SignalId = (typeof signals)[number]["id"]; +const color = Object.fromEntries(signals.map((s) => [s.id, s.color])) as Record; + +// Config keys take the color of the signal they collect. +const yamlKeys: [string, SignalId][] = [ + ["cpu", "cpu"], + ["memory", "memory"], + ["filesystem", "disk"], + ["windows_service", "services"], + ["windows_event_log/errors", "events"], + ["windows_event_log/security", "logins"], +]; +const configHtml = highlight(collectorConfig.trimEnd(), "yaml").replace(/([\w/]+)<\/span>:/g, (match, key: string) => { + const id = yamlKeys.find(([name]) => name === key)?.[1]; + return id ? `${key}:` : match; +}); + +const tools: { group: string; rows: { name: string; href: string; goodFor: string; alerts: string; cost: string; compare?: string }[] }[] = [ + { + group: "Built into Windows", + rows: [ + { + name: "Performance Monitor", + href: "https://learn.microsoft.com/en-us/troubleshoot/windows-server/performance/create-performance-counter-alert-send-email", + goodFor: "Live charts of any counter on one server, and logging counters to a file", + alerts: "A counter alert can write an event or start a task. Email needs a script.", + cost: "Included", + }, + { + name: "Event Viewer", + href: "https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-server-2008-r2-and-2008/cc766042(v=ws.10)", + goodFor: "Finding out why a service crashed or a login failed", + alerts: "Can run a task when a chosen event appears", + cost: "Included", + }, + { + name: "Windows Admin Center", + href: "https://learn.microsoft.com/en-us/windows-server/manage/windows-admin-center/overview", + goodFor: "Managing a handful of servers from a browser", + alerts: "None. Microsoft describes it as a complement to monitoring tools.", + cost: "Free download", + }, + ], + }, + { + group: "Open source", + rows: [ + { + name: "Zabbix", + href: "https://www.zabbix.com/license", + goodFor: "One self-hosted tool for servers and network gear, with a Windows agent", + alerts: "Built in, with history", + cost: "Free (AGPLv3). You run the server.", + }, + { + name: "Prometheus + windows_exporter", + href: "https://github.com/prometheus-community/windows_exporter", + goodFor: "Teams that already run Prometheus", + alerts: "Through Alertmanager. Metrics only, no Event Log.", + cost: "Free (MIT). You run Prometheus.", + }, + { + name: "OpenTelemetry Collector", + href: "https://github.com/open-telemetry/opentelemetry-collector-releases", + goodFor: "Sending Windows metrics and events to the backend your apps already use", + alerts: "In the backend it sends to", + cost: "Free (Apache 2.0). The backend is priced separately.", + }, + ], + }, + { + group: "Commercial", + rows: [ + { + name: "PRTG", + href: "https://www.paessler.com/prtg/pricing", + goodFor: "A Windows-hosted console with ready-made sensors", + alerts: "Built in", + cost: "Freeware up to 100 sensors. PRTG 500: $200/month, billed annually.", + }, + { + name: "Datadog", + href: "https://www.datadoghq.com/pricing/", + goodFor: "Hosted metrics, logs and APM with a Windows agent", + alerts: "Built in", + cost: "Free for 5 hosts with 1-day retention. Pro from $15/host/month, billed annually.", + compare: "/compare/datadog", + }, + ], + }, +]; + +const picks = [ + ["One server you log in to every day", "Performance Monitor and Event Viewer. An alert that reaches you still needs one of the other tools."], + ["Your apps already send OpenTelemetry data", "The OpenTelemetry Collector on each server, sending to the same backend, so one set of alert rules covers apps and servers"], + ["A Prometheus server you already run", "windows_exporter for metrics, plus something else for the Event Log"], + ["One self-hosted tool for servers and network gear", "Zabbix"], + ["No monitoring servers of your own to run", "A hosted product. Compare what a hundred more hosts or sensors would cost."], +]; + +const flow = [ + { name: "Windows Server", detail: "counters, services, Event Log", kind: "source" }, + { name: "otelcol-contrib", detail: "the Collector, as a Windows service", kind: "stage" }, + { name: "OTLP over HTTPS", detail: "the OpenTelemetry protocol", kind: "stage" }, + { name: "Your backend", detail: "Maple in this example", kind: "backend" }, +]; + +const receivers: { key: string; signals: SignalId[]; body: string }[] = [ + { key: "host_metrics", signals: ["cpu", "memory", "disk"], body: "CPU, memory, disk space and network every 30 seconds. The *.utilization metrics are off by default; they report each one as a share from 0 to 1, which is what the alerts compare." }, + { key: "windows_service", signals: ["services"], body: "The state of each service in include_services, once a minute, as windows.service.status. 4 means running." }, + { key: "windows_event_log/errors", signals: ["events"], body: "Critical, Error and Warning events from the System and Application logs. Information events stay on the server." }, + { key: "windows_event_log/security", signals: ["logins"], body: "Only failed logins (4625) and account lockouts (4740). The full Security log is large on a busy server." }, +]; + +const faqs = [ + { + question: "What are the best Windows server monitoring tools?", + answer: + "It depends on how many servers you run and what else you monitor. For one server you log in to, Performance Monitor and Event Viewer ship with Windows. For a few servers, pick a central tool that keeps history and sends alerts: Zabbix if you want to host it yourself, PRTG or Datadog as commercial options, or the OpenTelemetry Collector if your applications already send OpenTelemetry data to a backend.", + }, + { + question: "Are there free Windows server monitoring tools?", + answer: + "Yes. Performance Monitor and Event Viewer are part of Windows, and Windows Admin Center is a free download. Zabbix, Prometheus with windows_exporter and the OpenTelemetry Collector are open source. PRTG has a freeware edition for up to 100 sensors, and Datadog's free tier covers 5 hosts with 1-day metric retention.", + }, + { + question: "What should I monitor on a Windows server?", + answer: + "Start with the services your applications depend on, free disk space on every drive, CPU and memory over 15-minute windows, Error events in the System and Application logs, and failed logins (Security event 4625). Those signals catch most outages before users report them.", + }, + { + question: "How do I monitor Windows services?", + answer: + "On one server, Get-Service shows each service's state. To get an alert, collect the state on a schedule: the OpenTelemetry Collector's windows_service receiver reports windows.service.status once a minute, where 4 means running. Alert when the minimum value for a service drops below 4.", + }, + { + question: "Can I monitor a Windows server without installing an agent?", + answer: + "Partly. Performance Monitor and PowerShell's Get-Counter -ComputerName read counters from another server over the network, which works for a quick look. For history, Event Log collection and alerts that keep working when the network between servers is flaky, an agent on each server is simpler.", + }, + { + question: "Does the OpenTelemetry Collector run on Windows Server?", + answer: + "Yes. The opentelemetry-collector-releases project publishes an MSI for the otelcol-contrib distribution. It registers an automatic-start Windows service named otelcol-contrib that runs as LocalSystem and reads config.yaml from C:\\Program Files\\OpenTelemetry Collector.", + }, +]; + +const siteUrl = Astro.site?.toString().replace(/\/$/, "") ?? "https://maple.dev"; +const articleJsonLd = { + "@context": "https://schema.org", + "@type": "TechArticle", + headline: "Windows server monitoring tools and what to watch", + description: pageDescription, + datePublished: published, + dateModified: "2026-10-31", + author: { "@type": "Person", name: author.name, url: authorUrl }, + publisher: { + "@type": "Organization", + name: "Maple", + logo: { "@type": "ImageObject", url: `${siteUrl}/logo192.png` }, + }, + mainEntityOfPage: `${siteUrl}/guides/windows-server-monitoring`, +}; +--- + + + + + + diff --git a/apps/landing/src/pages/llms.txt.ts b/apps/landing/src/pages/llms.txt.ts index e06f8c2eab..fd29e67e0e 100644 --- a/apps/landing/src/pages/llms.txt.ts +++ b/apps/landing/src/pages/llms.txt.ts @@ -124,6 +124,7 @@ export const GET: APIRoute = ({ site }) => { `- [Loguru for Python](${url("/guides/loguru")})`, `- [Python logging levels and how to set them](${url("/guides/python-logging-levels")})`, `- [Incident response, step by step](${url("/guides/incident-response")})`, + `- [Windows server monitoring tools and what to watch](${url("/guides/windows-server-monitoring")})`, "", "## Maple API",