---
title: 'Full-Stack Monitoring: Prometheus + Grafana + Loki + Tempo + Bark'
url: https://doc.liz6.com/en/homelab/monitoring
locale: en
area: homelab
tags:
- homelab
date: 2026-07-01
modified: 2026-07-16
description: 'Metrics (Prometheus) + Logs (Loki) + Traces (Tempo) all feed into Grafana, with alerts pushed to iPhone via Bark. Metrics use two sets: pull (local network node_exporter) and push (remote otel-collector fleet agent, via rathole tunnel + mTLS);…'
---

# Full-Stack Monitoring: Prometheus + Grafana + Loki + Tempo + Bark

> Metrics (Prometheus) + Logs (Loki) + Traces (Tempo) all feed into Grafana, with alerts pushed to iPhone via Bark. Metrics use two sets: pull (local network node_exporter) and push (remote otel-collector fleet agent, via rathole tunnel + mTLS); logs go journald → otelcol-logs → Loki; traces go otelcol-home tail sampling → Tempo. Using local host `li-home-0` as the instance, this covers full-stack architecture, alert rules, collection comparison, labeling scheme, severity determination, and pitfall records.

## 1. Overview

<svg viewBox="0 0 780 360" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system,'Source Han Sans CN','Microsoft YaHei',sans-serif" role="img" aria-label="Monitoring Architecture Overview">
  <defs><marker id="mah" markerWidth="10" markerHeight="8" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6 Z" fill="#64748b"/></marker></defs>
  <rect width="780" height="360" fill="#ffffff"/>
  <text x="390" y="28" text-anchor="middle" font-size="17" font-weight="700" fill="#1f2933">Monitoring Architecture: Metrics + Logs + Traces → Grafana, Alerts → Bark → iPhone</text>
  <!-- Remote fleet -->
  <rect x="20" y="50" width="130" height="90" rx="8" fill="#fef3c7" stroke="#f59e0b"/>
  <text x="85" y="72" text-anchor="middle" font-size="10" font-weight="700" fill="#b45309">Remote Fleet</text>
  <text x="85" y="88" text-anchor="middle" font-size="9" fill="#d97706">otel-collector</text>
  <text x="85" y="102" text-anchor="middle" font-size="9" fill="#d97706">host_metrics 30s</text>
  <text x="85" y="116" text-anchor="middle" font-size="9" fill="#d97706">file_storage queue</text>
  <text x="85" y="130" text-anchor="middle" font-size="8" fill="#b45309">mTLS client cert</text>
  <line x1="150" y1="98" x2="200" y2="72" stroke="#f59e0b" stroke-width="1.3" marker-end="url(#mah)"/>
  <!-- rathole + Caddy -->
  <rect x="204" y="44" width="110" height="102" rx="6" fill="#e2e8f0" stroke="#94a3b8"/>
  <text x="259" y="68" text-anchor="middle" font-size="10" font-weight="700" fill="#475569">rathole</text>
  <text x="259" y="84" text-anchor="middle" font-size="9" fill="#64748b">TCP Tunnel</text>
  <text x="259" y="100" text-anchor="middle" font-size="9" fill="#64748b">↓ Caddy</text>
  <text x="259" y="116" text-anchor="middle" font-size="9" fill="#64748b">mTLS Termination</text>
  <text x="259" y="132" text-anchor="middle" font-size="9" fill="#64748b">Path Restriction + Revocation</text>
  <line x1="314" y1="95" x2="356" y2="95" stroke="#475569" stroke-width="1.3" marker-end="url(#mah)"/>
  <!-- otelcol-home -->
  <rect x="360" y="50" width="110" height="90" rx="8" fill="#e0e7ff" stroke="#818cf8"/>
  <text x="415" y="72" text-anchor="middle" font-size="10" font-weight="700" fill="#3730a3">otelcol-home</text>
  <text x="415" y="88" text-anchor="middle" font-size="9" fill="#4f46e5">OTLP Receiver</text>
  <text x="415" y="102" text-anchor="middle" font-size="9" fill="#4f46e5">metrics pipeline</text>
  <text x="415" y="116" text-anchor="middle" font-size="9" fill="#4f46e5">traces pipeline</text>
  <text x="415" y="130" text-anchor="middle" font-size="9" fill="#4f46e5">tail sampling</text>
  <!-- metrics→Prom -->
  <line x1="470" y1="80" x2="532" y2="80" stroke="#3b82f6" stroke-width="1.5" marker-end="url(#mah)"/>
  <!-- traces→Tempo -->
  <line x1="470" y1="118" x2="532" y2="118" stroke="#8b5cf6" stroke-width="1.5" marker-end="url(#mah)"/>
  <rect x="536" y="56" width="90" height="24" rx="6" fill="#dbeafe" stroke="#3b82f6"/>
  <text x="581" y="72" text-anchor="middle" font-size="10" font-weight="700" fill="#1e40af">Prometheus</text>
  <rect x="536" y="106" width="90" height="24" rx="6" fill="#ede9fe" stroke="#a78bfa"/>
  <text x="581" y="122" text-anchor="middle" font-size="10" font-weight="700" fill="#6d28d9">Tempo :4317</text>
  <!-- Local pull -->
  <rect x="646" y="56" width="116" height="22" rx="4" fill="#f1f5f9"/>
  <text x="704" y="71" text-anchor="middle" font-size="9" fill="#64748b">node_exporter (pull)</text>
  <line x1="646" y1="67" x2="630" y2="67" stroke="#94a3b8" stroke-width="1" stroke-dasharray="2 4"/>
  <!-- Logs row -->
  <rect x="20" y="176" width="82" height="24" rx="5" fill="#fce7f3"/><text x="61" y="192" text-anchor="middle" font-size="9" font-weight="700" fill="#9d174d">journald</text>
  <line x1="102" y1="188" x2="144" y2="188" stroke="#ec4899" stroke-width="1.3" marker-end="url(#mah)"/>
  <rect x="148" y="176" width="82" height="24" rx="5" fill="#fce7f3"/><text x="189" y="192" text-anchor="middle" font-size="9" font-weight="700" fill="#9d174d">otelcol-logs</text>
  <line x1="230" y1="188" x2="278" y2="188" stroke="#ec4899" stroke-width="1.3" marker-end="url(#mah)"/>
  <rect x="282" y="176" width="70" height="24" rx="5" fill="#fce7f3"/><text x="317" y="192" text-anchor="middle" font-size="9" font-weight="700" fill="#9d174d">Loki :3100</text>
  <!-- Grafana -->
  <rect x="440" y="166" width="110" height="44" rx="8" fill="#dcfce7" stroke="#22c55e"/>
  <text x="495" y="186" text-anchor="middle" font-size="11" font-weight="700" fill="#166534">Grafana</text>
  <text x="495" y="201" text-anchor="middle" font-size="9" fill="#15803d">Metrics · Logs · Traces</text>
  <!-- Bark -->
  <rect x="580" y="166" width="80" height="44" rx="8" fill="#fff7ed" stroke="#f97316"/>
  <text x="620" y="186" text-anchor="middle" font-size="11" font-weight="700" fill="#c2410c">Bark</text>
  <text x="620" y="201" text-anchor="middle" font-size="9" fill="#ea580c">APNs → iPhone</text>
  <!-- arrows to Grafana -->
  <line x1="626" y1="69" x2="545" y2="180" stroke="#94a3b8" stroke-width="1" stroke-dasharray="2 4"/>
  <line x1="626" y1="118" x2="545" y2="183" stroke="#94a3b8" stroke-width="1" stroke-dasharray="2 4"/>
  <line x1="352" y1="188" x2="436" y2="188" stroke="#94a3b8" stroke-width="1" stroke-dasharray="2 4"/>
  <!-- bottom notes -->
  <rect x="20" y="228" width="740" height="70" rx="8" fill="#f8fafc" stroke="#e2e8f0"/>
  <text x="40" y="250" font-size="10" fill="#475569">metrics-fleet: Remote otel-collector (fleet agent) → OTLP+mTLS → rathole tunnel → Caddy gateway → otelcol-home (metrics+trace pipeline)</text>
  <text x="40" y="268" font-size="10" fill="#475569">Local: node_exporter → Prometheus (pull) · journald → otelcol-logs → Loki · otelcol-home traces → Tempo (gRPC, 720h retention)</text>
  <text x="40" y="286" font-size="10" fill="#475569">Grafana unified query for Metrics (Prometheus) + Logs (Loki) + Traces (Tempo) · Alerts via Bark webhook → APNs → iPhone (local bark-server, no tunnel/public internet dependency)</text>
  <rect x="20" y="310" width="740" height="22" rx="5" fill="#f1f5f9"/>
  <text x="390" y="326" text-anchor="middle" font-size="9" fill="#94a3b8">All components native systemd · bind 127.0.0.1 · Docker only runs applications</text>
</svg>

## 2. Metrics Collection: Two Models

| | Traditional Pull (node_exporter) | Remote Push (metrics-fleet) |
|---|---|---|
| Use Case | Local network nodes, local host | Public internet remote servers |
| Collector | node_exporter / LHM exporter | otel-collector-contrib (host_metrics) |
| Metric Naming | `node_*`, `windows_*`, `lhm_*` | `system_cpu_*`, `system_memory_*` |
| Network | Prometheus actively pulls, requires reachable ports | Remote actively pushes, zero inbound ports |
| Transport Security | None (unencrypted on LAN) | mTLS + Path Restriction (only OTLP write allowed) |
| Onboarding Method | Manually edit `prometheus.yml` `static_configs` | `add-server.sh <ssh-alias>` |
| Dashboard | One independent JSON per host | Single template, `$host` dropdown to switch |
| Transit Dependency | None | rathole tunnel → Transit VPS → Home |

### Pull Model: scrape_configs

Prometheus pulls these targets every 15s: local host node_exporter (:9100), llama-server (:18080), Home Assistant (:8123), Windows machines (9182/9183), N100 side router (:9100), Xiaomi router (:9184), self-monitoring (:9090).

### Push Model: OTLP Receiver

Remote servers collect host_metrics via otel-collector, authenticate via mTLS + rathole encrypted tunnel, and push to the Prometheus remote write endpoint at home:

<svg viewBox="0 0 720 330" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system,'Source Han Sans CN','Microsoft YaHei',sans-serif" role="img" aria-label="Push Model: Remote metrics pushed home via mTLS and rathole tunnel">
  <defs><marker id="oah" markerWidth="10" markerHeight="8" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6 Z" fill="#475569"/></marker></defs>
  <rect width="720" height="330" fill="#ffffff"/>
  <text x="360" y="28" text-anchor="middle" font-size="17" font-weight="700" fill="#1f2933">Push Model: Remote metrics pushed home via mTLS + rathole tunnel</text>
  <!-- Row 1 -->
  <rect x="20" y="60" width="200" height="70" rx="8" fill="#fef3c7" stroke="#f59e0b"/>
  <text x="120" y="88" text-anchor="middle" font-size="11" font-weight="700" fill="#b45309">Remote otel-collector</text>
  <text x="120" y="106" text-anchor="middle" font-size="9" fill="#d97706">host_metrics Collection</text>
  <line x1="220" y1="95" x2="260" y2="95" stroke="#475569" stroke-width="1.6" marker-end="url(#oah)"/>
  <text x="240" y="85" text-anchor="middle" font-size="8" fill="#475569">OTLP+mTLS Cert</text>
  <rect x="260" y="60" width="200" height="70" rx="8" fill="#e2e8f0" stroke="#94a3b8"/>
  <text x="360" y="86" text-anchor="middle" font-size="11" font-weight="700" fill="#475569">Public Entry</text>
  <text x="360" y="103" text-anchor="middle" font-size="9" fill="#64748b">otlp.liz6.com:9443</text>
  <text x="360" y="117" text-anchor="middle" font-size="9" fill="#64748b">DNS → Transit VPS</text>
  <line x1="460" y1="95" x2="500" y2="95" stroke="#475569" stroke-width="1.6" marker-end="url(#oah)"/>
  <rect x="500" y="60" width="200" height="70" rx="8" fill="#e2e8f0" stroke="#94a3b8"/>
  <text x="600" y="88" text-anchor="middle" font-size="11" font-weight="700" fill="#475569">rathole Tunnel</text>
  <text x="600" y="106" text-anchor="middle" font-size="9" fill="#64748b">TCP Passthrough · Ciphertext Only</text>
  <!-- connector row1 -> row2 -->
  <path d="M600,130 L600,152 L130,152 L130,170" fill="none" stroke="#475569" stroke-width="1.6" marker-end="url(#oah)"/>
  <!-- Row 2 -->
  <rect x="20" y="170" width="220" height="70" rx="8" fill="#e2e8f0" stroke="#94a3b8"/>
  <text x="130" y="196" text-anchor="middle" font-size="11" font-weight="700" fill="#475569">Home Caddy mTLS Gateway</text>
  <text x="130" y="213" text-anchor="middle" font-size="9" fill="#64748b">Verify Cert + Revocation Check</text>
  <text x="130" y="227" text-anchor="middle" font-size="9" fill="#64748b">Only Allow /api/v1/otlp/*</text>
  <line x1="240" y1="205" x2="280" y2="205" stroke="#475569" stroke-width="1.6" marker-end="url(#oah)"/>
  <rect x="280" y="170" width="210" height="70" rx="8" fill="#e0e7ff" stroke="#818cf8"/>
  <text x="385" y="196" text-anchor="middle" font-size="11" font-weight="700" fill="#3730a3">otelcol-home</text>
  <text x="385" y="213" text-anchor="middle" font-size="9" fill="#4f46e5">:4318</text>
  <text x="385" y="227" text-anchor="middle" font-size="9" fill="#4f46e5">batch Processing</text>
  <line x1="490" y1="205" x2="530" y2="205" stroke="#475569" stroke-width="1.6" marker-end="url(#oah)"/>
  <rect x="530" y="170" width="170" height="70" rx="8" fill="#dbeafe" stroke="#3b82f6"/>
  <text x="615" y="196" text-anchor="middle" font-size="11" font-weight="700" fill="#1e40af">Prometheus</text>
  <text x="615" y="213" text-anchor="middle" font-size="9" fill="#1e40af">remote write</text>
  <text x="615" y="227" text-anchor="middle" font-size="9" fill="#1e40af">:9090</text>
  <!-- bottom note -->
  <rect x="20" y="258" width="680" height="50" rx="8" fill="#f8fafc" stroke="#e2e8f0"/>
  <text x="40" y="278" font-size="10" fill="#475569">mTLS terminates only at Caddy; rathole passes ciphertext end-to-end, so we never see plaintext traffic.</text>
  <text x="40" y="294" font-size="10" fill="#475569">Prometheus config `out_of_order_time_window=30m` to accommodate out-of-order arrival across the public internet.</text>
</svg>

> Prometheus config `out_of_order_time_window: 30m` accommodates out-of-order arrival across the public internet.

### Grafana Dashboards

Provisioning directory: 7 host panels (CPU / GPU / Memory / Disk / Cooling, per-node JSON) + 1 server status template (`$host` dropdown auto-discovers all host_names pushed via OTLP) + Network monitoring panel.

Panel expression example:
```promql
# CPU: 100*(1-avg by(host_name)(system_cpu_utilization_ratio{host_name="$host",state="idle"}))
# Memory: 100*system_memory_utilization_ratio{host_name="$host",state="used"}
# Liveness check (push model has no up metric, use last reported time instead):
time()-max(timestamp(system_memory_usage_bytes{host_name="$host"}))
```

## 3. Security: CA and mTLS

Security for the remote push chain is ensured by client certificates issued by a private CA:

```
~/infra/metrics-fleet/ca/
├── make-ca.sh              # One-time CA creation (EC P-256, 10 years)
├── issue-server-cert.sh    # Issue server cert → /etc/caddy/metrics-fleet/
├── issue-client-cert.sh    # Issue client cert → out/clients/<name>/
├── revoke-client-cert.sh   # Revoke + update Caddy revoked.caddy + reload
└── out/clients/<name>/     # Issued client certs (one per remote host)
```

Caddy acts as an mTLS gateway on 127.0.0.1:9443: verifies client cert (require_and_verify, trust CA), checks revocation list → 403, only allows `/api/v1/otlp/*` with strip_prefix → otelcol-home :4318.

> The public IP of each remote server must be added to the DIRECT rules in mihomo on both the local host and the N100, ensuring rathole connections and SSH do not go through the proxy (transport connections must use native routing—see [rathole-tunnel.md](rathole-tunnel.md) section 4.3).

## 4. Alerts: Grafana + Bark Push to iPhone

Prometheus handles collection, Grafana handles evaluation and notification. Alert chain:

<svg viewBox="0 0 720 200" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system,'Source Han Sans CN','Microsoft YaHei',sans-serif" role="img" aria-label="Alert Chain: Grafana rule evaluation via local bark-server pushed to iPhone">
  <defs><marker id="bah" markerWidth="10" markerHeight="8" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6 Z" fill="#475569"/></marker></defs>
  <rect width="720" height="200" fill="#ffffff"/>
  <text x="360" y="28" text-anchor="middle" font-size="17" font-weight="700" fill="#1f2933">Alert Chain: Grafana Rule Evaluation → bark-server (local) → APNs → iPhone</text>
  <rect x="20" y="60" width="190" height="70" rx="8" fill="#dcfce7" stroke="#22c55e"/>
  <text x="115" y="92" text-anchor="middle" font-size="12" font-weight="700" fill="#166534">Grafana Rule Evaluation</text>
  <text x="115" y="112" text-anchor="middle" font-size="10" fill="#15803d">Evaluated every 30s</text>
  <line x1="210" y1="95" x2="250" y2="95" stroke="#475569" stroke-width="1.6" marker-end="url(#bah)"/>
  <text x="230" y="85" text-anchor="middle" font-size="9" fill="#475569">webhook</text>
  <rect x="250" y="60" width="230" height="70" rx="8" fill="#fff7ed" stroke="#f97316"/>
  <text x="365" y="88" text-anchor="middle" font-size="12" font-weight="700" fill="#c2410c">bark-server</text>
  <text x="365" y="104" text-anchor="middle" font-size="10" fill="#ea580c">127.0.0.1:8085</text>
  <text x="365" y="119" text-anchor="middle" font-size="9" fill="#ea580c">Local direct connection, no tunnel</text>
  <line x1="480" y1="95" x2="520" y2="95" stroke="#475569" stroke-width="1.6" marker-end="url(#bah)"/>
  <text x="500" y="85" text-anchor="middle" font-size="9" fill="#475569">APNs</text>
  <rect x="520" y="60" width="180" height="70" rx="8" fill="#ccfbf1" stroke="#99f6e4"/>
  <text x="610" y="92" text-anchor="middle" font-size="12" font-weight="700" fill="#115e59">iPhone</text>
  <text x="610" y="112" text-anchor="middle" font-size="10" fill="#0f766e">Lock Screen Push</text>
  <rect x="20" y="150" width="680" height="40" rx="8" fill="#f0fdfa" stroke="#99f6e4"/>
  <text x="360" y="174" text-anchor="middle" font-size="12" fill="#115e59">Why use local bark-server: Alert notifications cannot depend on tunnels/public internet—if the chain is down, alerts can't be sent. Local direct connection has zero external dependencies.</text>
</svg>

**Why use local bark-server instead of public webhook**: If alert notifications go via rathole tunnel or public internet—when the tunnel is down, alerts can't be sent. Local bark-server connects directly to iPhone, zero external dependencies, not bound to infrastructure.

### Contacts and Notification Strategy

```yaml
# /etc/grafana/provisioning/alerting/contact-points.yaml
contactPoints:
  - name: bark-home
    receivers:
      - type: webhook
        settings:
          url: "http://127.0.0.1:8085/<token>"   # Local bark-server, bypass tunnel
          maxAlerts: 2                            # APNs 4KB limit, >2 alerts causes PayloadTooLarge
          title: "[{{ .Status }}] {{ .CommonLabels.grafana_folder }}{{ if .CommonLabels.alertname }} · {{ .CommonLabels.alertname }}{{ end }}"
          message: "{{ range .Alerts }}{{ .Annotations.summary }}\n{{ end }}"

policies:
  - receiver: bark-home
    group_by: ["grafana_folder", "alertname"]     # Merge similar, send different separately
    group_wait: 30s                               # Initial delay to wait for similar alerts
    group_interval: 5m                            # Interval for subsequent alerts in same group
    repeat_interval: 4h                           # Re-alert every 4h if unresolved
```

> `maxAlerts: 2` is key—bark passes the entire webhook JSON to APNs, each alert is ~1.1–1.4KB, exceeding 2 alerts surpasses APNs 4KB limit, bark returns 500.

### Alert Rules (13 total)

| Rule | Severity | Condition | Silence |
|------|----------|-----------|---------|
| Side router down | critical | `up{job="li-home-router"}==0` | 3m |
| Router unreachable | critical | `ping_loss{target="gateway"}>30%` | 2m |
| High external packet loss | warning | `ping_loss{target="223.5.5.5"}>20%` | 3m |
| High TCP retransmission | warning | `Tcp_RetransSegs / Tcp_OutSegs > 10%` | 5m |
| DNS all slow | warning | All three domain resolutions >100ms | 5m |
| mihomo TUN failure | critical | `nikki` network interface metrics missing | 2m |
| CN node disk low | warning | Root partition remaining <5GB | 5m |
| Water pump stopped | critical | fan7 RPM <1500 | 5m |
| VRM overheating | warning | temp5 >90°C | 2m |
| GPU hotspot overheating | warning | 7900XTX junction temp >100°C | 2m |
| N100 CPU overload | warning | CPU >90% | 5m |
| Local exporter down | critical | `up{job="local-info"}==0` | 2m |
| rathole service unreachable | critical | `rathole_service_up < 1` (tagged by service) | 3m |

> Rules are managed via Grafana provisioning (`/etc/grafana/provisioning/alerting/home-network.yaml`), versioned in git, no need to enter Web UI to change rules. Each alert describes the phenomenon in `annotations.summary`, and provides troubleshooting steps in `annotations.runbook`—alert notifications only push summary, runbook is expanded in Grafana.

### Metrics Pitfalls

- **N100 side router onboarding**: Prometheus scraping N100 requires going through local host proxy, add DIRECT rule to bypass.

## 5. Log Monitoring (Loki + OpenTelemetry)

Fills the gap of "only metrics, no logs". All local system logs (kernel + systemd services + Docker containers) go uniformly into journald → otelcol-logs → Loki → Grafana Explore for retrieval.

### Design Trade-offs

- **Choose OpenTelemetry, not Promtail/Vector**: Local host already runs otelcol-home, introducing Vector would duplicate collector tech stack. OTel also makes remote host log onboarding nearly free (reuse same mTLS/rathole/Caddy tunnel).
- **Loki uses native OTLP endpoint** `/otlp/v1/logs` (old `lokiexporter` is deprecated).
- **Docker changed to journald log driver**: Container logs with `CONTAINER_NAME` go into journald, collected by same `journaldreceiver` → has real container names, single data source.
- **native systemd**, bind `127.0.0.1`, 90-day retention, no log dashboard (use Explore directly).

### Data Flow

<svg viewBox="0 0 760 240" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system,'Source Han Sans CN','Microsoft YaHei',sans-serif" role="img" aria-label="Log Data Flow">
  <defs><marker id="lah" markerWidth="10" markerHeight="8" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6 Z" fill="#64748b"/></marker></defs>
  <rect width="760" height="240" fill="#ffffff"/>
  <text x="360" y="28" text-anchor="middle" font-size="17" font-weight="700" fill="#1f2933">Log Data Flow: journald → otelcol-logs → Loki → Grafana</text>
  <!-- Sources -->
  <rect x="20" y="54" width="88" height="28" rx="5" fill="#e2e8f0"/><text x="64" y="73" text-anchor="middle" font-size="11" fill="#334155">Kernel dmesg</text>
  <rect x="20" y="86" width="88" height="28" rx="5" fill="#e2e8f0"/><text x="64" y="105" text-anchor="middle" font-size="11" fill="#334155">systemd Services</text>
  <rect x="20" y="118" width="88" height="28" rx="5" fill="#e2e8f0"/><text x="64" y="137" text-anchor="middle" font-size="11" fill="#334155">Docker Containers</text>
  <!-- Merge lines -->
  <line x1="108" y1="68" x2="152" y2="60" stroke="#94a3b8" stroke-width="1"/>
  <line x1="108" y1="100" x2="148" y2="92" stroke="#94a3b8" stroke-width="1"/>
  <line x1="108" y1="132" x2="152" y2="124" stroke="#94a3b8" stroke-width="1"/>
  <!-- journald -->
  <rect x="156" y="58" width="140" height="76" rx="8" fill="#e0e7ff" stroke="#818cf8"/>
  <text x="226" y="80" text-anchor="middle" font-size="12" font-weight="700" fill="#3730a3">journald</text>
  <text x="226" y="97" text-anchor="middle" font-size="9" fill="#4f46e5">Persistent /var/log/journal</text>
  <text x="226" y="113" text-anchor="middle" font-size="9" fill="#4f46e5">SystemMaxUse=8G</text>
  <line x1="296" y1="96" x2="352" y2="96" stroke="#475569" stroke-width="1.8" marker-end="url(#lah)"/>
  <!-- otelcol-logs -->
  <rect x="356" y="58" width="156" height="76" rx="8" fill="#fef3c7" stroke="#f59e0b"/>
  <text x="434" y="78" text-anchor="middle" font-size="11" font-weight="700" fill="#b45309">otelcol-logs</text>
  <text x="434" y="95" text-anchor="middle" font-size="9" fill="#d97706">journaldreceiver + OTTL</text>
  <text x="434" y="109" text-anchor="middle" font-size="9" fill="#d97706">resource_detection+batch</text>
  <text x="434" y="123" text-anchor="middle" font-size="9" fill="#d97706">User=otelcol-logs</text>
  <line x1="512" y1="96" x2="570" y2="96" stroke="#475569" stroke-width="1.8" marker-end="url(#lah)"/>
  <!-- Loki -->
  <rect x="574" y="58" width="52" height="76" rx="6" fill="#fce7f3" stroke="#ec4899"/>
  <text x="600" y="86" text-anchor="middle" font-size="11" font-weight="700" fill="#be185d">Loki</text>
  <text x="600" y="103" text-anchor="middle" font-size="9" fill="#9d174d">90d</text>
  <line x1="626" y1="96" x2="678" y2="96" stroke="#475569" stroke-width="1.8" marker-end="url(#lah)"/>
  <!-- Grafana -->
  <rect x="682" y="58" width="52" height="76" rx="6" fill="#dcfce7" stroke="#22c55e"/>
  <text x="708" y="86" text-anchor="middle" font-size="11" font-weight="700" fill="#166534">Grafana</text>
  <text x="708" y="103" text-anchor="middle" font-size="9" fill="#15803d">Explore</text>
  <!-- bottom notes -->
  <rect x="60" y="148" width="640" height="60" rx="8" fill="#f8fafc" stroke="#e2e8f0"/>
  <text x="100" y="170" font-size="10" fill="#475569">Key: journaldreceiver puts fields into body (Map), OTTL first extracts attributes from body then sets (body, body["MESSAGE"]) to restore.</text>
  <text x="100" y="186" font-size="10" fill="#475569">Docker side /etc/docker/daemon.json: log-driver=journald + tag={{.Name}} + live-restore=true</text>
  <text x="100" y="202" font-size="10" fill="#475569">Loki side: common.instance_addr=127.0.0.1 (otherwise ring broadcast IP self-connection fails), discover_log_levels=true</text>
</svg>

### Components

| Component | Location | Notes |
|------|------|------|
| Loki 3.7 | `/usr/local/bin/loki`, conf `/etc/loki` | `:3100`, OTLP `/otlp/v1/logs`, user `loki` |
| otelcol-logs | `/usr/local/bin/otelcol-contrib` (0.154, reuse fleet binary) | unit `/etc/systemd/system/otelcol-logs.service`, no root needed (group `systemd-journal`) |
| Grafana Data Source | `/etc/grafana/provisioning/datasources/loki.yaml` | uid `loki-local` |

### Labeling Scheme

Low cardinality goes to **index labels**, high cardinality goes to **structured metadata** (queryable but not indexed):

| Index Label | Meaning |
|----------|------|
| `host_name` | resource `host.name` (whole machine name, aligns with metrics) |
| `service_name` | = `host.name`, Loki native grouping field |
| `unit` | systemd service name; Docker container = container name (overwritten by OTTL from `CONTAINER_NAME`) |
| `container` | Docker container name (container logs only) |
| `level` | See "Severity Determination" below |
| `transport` | kernel / stdout / syslog / journal / audit |

### Severity Determination (Hybrid Strategy)

journald's `PRIORITY` is only the true severity for native sources (systemd-journal/syslog/kernel); `PRIORITY` for container logs and raw stdout/stderr only represents the stream (stdout=6/stderr=3), meaningless.

1. **Baseline**: Containers and non-stdout sources get severity based on PRIORITY (0–2 critical / 3 error / 4 warn / 5–6 info / 7 debug); container logs default to `info`.
2. **Body Override**: OTTL `IsMatch` identifies `[error]`/`level=info`/`"level":"warn"` and uppercase standalone words `WARNING`/`ERROR` from message body.
3. Loki side `discover_log_levels: true`—Loki recognizes `level` attribute and fills `detected_level` accordingly (used for Grafana severity visualization).

### Query Examples (Grafana Explore)

```logql
{unit="sshd.service"}
{level="error"}
{container="adguardhome"}
{transport="kernel"}
{service_name="li-home-0"} |= "timeout"
```

## 6. Log Pitfalls

1. **Loki single binary ring self-connection failure**: Default broadcasts network card IP, but gRPC only listens on 127.0.0.1 → connection refused. Must set `common.instance_addr: 127.0.0.1`.
2. **journaldreceiver puts entire log in body (Map)**: Fields are `body["PRIORITY"]` not attributes. OTTL first extracts attributes from body, finally `set(body, body["MESSAGE"])`.
3. **service_name semantics**: What we want is whole machine identifier, resource-level. Loki `discover_service_name` only reads resource attributes → set `service.name = host.name` in OTTL resource context.
4. **Docker container unit**: Under journald driver, `_SYSTEMD_UNIT=docker.service` is useless → OTTL overwrites with `CONTAINER_NAME`.
5. **Don't trust PRIORITY for severity**: adguard/HA/owntracks write info+warning all to stderr → PRIORITY all 3, if trusted they all become error. Don't set `discover_log_levels` to false—otherwise `detected_level` disappears, Grafana histogram collapses to single line.
6. **Colored logs = byte arrays**: MESSAGEs containing ANSI are stored as int-slice by journald, OTTL cannot decode. **Root fix = disable color at source**: containers add `NO_COLOR=1`+`PY_COLORS=0`; Rust services add `Environment=NO_COLOR=1`+`RUST_LOG_STYLE=never`.
7. **node_exporter sgcc spam**: `sgcc_ts.prom` has timestamps (orphan files owned by root), textfile collector doesn't support. Remove from `textfile_collector/` to fix.
8. **runlike rebuild container loses data**: `uvx runlike` outputs errors for anonymous/named volumes, need to explicitly pin volumes via `docker inspect .Mounts`.

## 7. Traces: Tempo + tail sampling

Traces and metrics share the same OTLP entry point (otelcol-home :4318), internally split by pipeline:

<svg viewBox="0 0 720 220" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system,'Source Han Sans CN','Microsoft YaHei',sans-serif" role="img" aria-label="Trace Chain: Applications via otelcol-home tail sampling into Tempo then Grafana">
  <defs><marker id="tah" markerWidth="10" markerHeight="8" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6 Z" fill="#475569"/></marker></defs>
  <rect width="720" height="220" fill="#ffffff"/>
  <text x="360" y="28" text-anchor="middle" font-size="17" font-weight="700" fill="#1f2933">Trace Chain: Applications → otelcol-home → tail sampling → Tempo → Grafana</text>
  <rect x="20" y="60" width="130" height="90" rx="8" fill="#e2e8f0" stroke="#94a3b8"/>
  <text x="85" y="100" text-anchor="middle" font-size="12" font-weight="700" fill="#334155">Applications/Services</text>
  <text x="85" y="118" text-anchor="middle" font-size="9" fill="#475569">OTLP trace</text>
  <text x="85" y="131" text-anchor="middle" font-size="9" fill="#475569">exporter</text>
  <line x1="150" y1="105" x2="185" y2="105" stroke="#475569" stroke-width="1.6" marker-end="url(#tah)"/>
  <rect x="185" y="60" width="230" height="90" rx="8" fill="#e0e7ff" stroke="#818cf8"/>
  <text x="300" y="82" text-anchor="middle" font-size="12" font-weight="700" fill="#3730a3">otelcol-home</text>
  <text x="300" y="98" text-anchor="middle" font-size="9" fill="#4f46e5">:4318 (same metrics entry)</text>
  <text x="300" y="112" text-anchor="middle" font-size="9" fill="#4f46e5">pipeline: memory_limiter</text>
  <text x="300" y="126" text-anchor="middle" font-size="9" fill="#4f46e5">→ tail_sampling → batch</text>
  <line x1="415" y1="105" x2="450" y2="105" stroke="#475569" stroke-width="1.6" marker-end="url(#tah)"/>
  <rect x="450" y="60" width="120" height="90" rx="8" fill="#ede9fe" stroke="#a78bfa"/>
  <text x="510" y="100" text-anchor="middle" font-size="12" font-weight="700" fill="#6d28d9">Tempo</text>
  <text x="510" y="118" text-anchor="middle" font-size="9" fill="#7c3aed">:4317 · gRPC</text>
  <line x1="570" y1="105" x2="605" y2="105" stroke="#475569" stroke-width="1.6" marker-end="url(#tah)"/>
  <rect x="605" y="60" width="95" height="90" rx="8" fill="#dcfce7" stroke="#22c55e"/>
  <text x="652" y="95" text-anchor="middle" font-size="11" font-weight="700" fill="#166534">Grafana</text>
  <text x="652" y="111" text-anchor="middle" font-size="9" fill="#15803d">Explore</text>
  <text x="652" y="125" text-anchor="middle" font-size="8" fill="#15803d">tempo-local</text>
  <rect x="20" y="160" width="680" height="45" rx="8" fill="#eef2ff" stroke="#c7d2fe"/>
  <text x="360" y="180" text-anchor="middle" font-size="12" fill="#3730a3">Traces and metrics share the same OTLP entry point (otelcol-home :4318), internally split by pipeline, non-interfering.</text>
  <text x="360" y="196" text-anchor="middle" font-size="12" fill="#3730a3">tail_sampling ensures span tree integrity, avoiding broken chains from inconsistent parent/child span sampling.</text>
</svg>

### Sampling Strategy

Uses **tail sampling** (not head sampling), ensuring span tree integrity is not truncated:

| Strategy | Condition | Sampling Rate |
|------|------|--------|
| errors-only | `status_code == ERROR` | 100% |
| latency-sampling | Latency >500ms | 100% |
| probabilistic | Other normal requests | 10% |

> tail sampling has 10s decision delay compared to head sampling (waits for span tree to be complete before deciding), but avoids "parent span sampled, child span not sampled" broken chains—crucial for troubleshooting distributed call chains.

### Tempo Backend

```yaml
# /etc/tempo/config.yaml
distributor:
  receivers:
    otlp:
      protocols:
        grpc:
          endpoint: 127.0.0.1:4317
ingester:
  max_block_duration: 5m
  lifecycler:
    ring:
      kvstore: { store: inmemory }
      replication_factor: 1
```

Single instance inmemory ring (no cluster requirement), 720h (30 days) retention. Same pattern as Loki: native systemd, bind `127.0.0.1`, Grafana Explore directly retrieves by Trace ID.

### Onboarding Method

Application side sets `OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318` (local) or `https://otlp.liz6.com:9443` (remote, via fleet tunnel). SDK automatically generates traces. Remote applications reuse existing fleet collector's mTLS certificates—metrics and traces share the same pipeline and security system.

## 8. Future Expansion: Remote Fleet Logs

Reuse existing metrics-fleet transport (no need to modify Caddy, `/api/v1/otlp/*` already covers `v1/logs`):
1. Remote otelcol-fleet agent adds journald receiver + logs pipeline → `otlphttp` to `otlp.liz6.com:9443`
2. Home otelcol-home adds logs pipeline → `otlphttp` to Loki `/otlp/v1/logs`
3. `add-server.sh`/CA/naming all reused, `host_name`/`service_name` are each node's name

## Related Documents

- [network-architecture.md](network-architecture.md) — Network topology and daemons
- [rathole-tunnel.md](rathole-tunnel.md) — Tunnel architecture (transport connections bypass mihomo)
- [server-lifecycle.md](server-lifecycle.md) — Remote server onboarding script (add-server)
