feat(observability): add OpenTelemetry Collector, Jaeger, and custom dashboard for monitoring

fix(infra): update NATS configuration for OpenTelemetry tracing and JetStream support
refactor(dapr): modify Dapr sidecar configuration to use new resources path and config file
chore(docs): update deployment documentation to include observability services
docs(commit-convention): establish commit message guidelines for Asepharyana Hub
docs(deploy-workflow): outline CI/CD pipeline and manual deployment steps for Asepharyana Hub
feat(event-driven): implement event-driven architecture patterns with Dapr and NATS
docs(hub-rules): define repository structure, submodule strategy, and infrastructure patterns
This commit is contained in:
asepharyana
2026-07-22 16:43:37 +07:00
parent df71487563
commit dcb93035f3
20 changed files with 1055 additions and 23 deletions
+2 -3
View File
@@ -11,11 +11,10 @@ services:
- '4222:4222' # client connections
- '8222:8222' # HTTP monitor / health
command:
- '-js'
- '-sd'
- '/data'
- '--config=/etc/nats/nats.conf'
volumes:
- nats_data:/data
- ../../infra/nats/nats.conf:/etc/nats/nats.conf:ro
volumes:
nats_data:
+58
View File
@@ -0,0 +1,58 @@
services:
# ── OpenTelemetry Collector ──
otel-collector:
container_name: otel-collector
image: otel/opentelemetry-collector-contrib:latest
restart: always
networks:
app-shared-net:
aliases:
- otel-collector
ports:
- '4317:4317' # OTLP gRPC
- '4318:4318' # OTLP HTTP
- '8889:8889' # Prometheus metrics
command:
- '--config=/etc/otel/config.yml'
volumes:
- ../../infra/otel/otel-collector-config.yml:/etc/otel/config.yml:ro
depends_on:
jaeger:
condition: service_started
# ── Jaeger (Tracing Backend + Built-in UI) ──
jaeger:
container_name: jaeger
image: jaegertracing/all-in-one:latest
restart: always
networks:
app-shared-net:
aliases:
- jaeger
ports:
- '16686:16686' # Jaeger UI + API
environment:
- COLLECTOR_OTLP_ENABLED=true
- COLLECTOR_ZIPKIN_HOST_PORT=:9411
- METRICS_STORAGE_TYPE=prometheus
- PROMETHEUS_SERVER_URL=http://otel-collector:8889
- LOG_LEVEL=info
# ── Custom Dashboard (nginx:alpine) ──
dashboard:
container_name: dashboard
build:
context: ../..
dockerfile: infra/docker/dashboard.Dockerfile
restart: always
networks:
app-shared-net:
aliases:
- dashboard
ports:
- '8080:8080'
networks:
app-shared-net:
name: app-shared-net
external: true
+5 -2
View File
@@ -35,6 +35,8 @@ services:
condition: service_started
dapr-placement:
condition: service_started
otel-collector:
condition: service_started
networks:
- app-shared-net
command:
@@ -44,9 +46,10 @@ services:
- '--dapr-http-port=3500'
- '--dapr-grpc-port=50001'
- '--placement-host-address=dapr-placement:50005'
- '--resources-path=/components'
- '--config=/dapr/config.yaml'
- '--resources-path=/dapr/components'
volumes:
- ../../infra/dapr/components:/components
- ../../infra/dapr:/dapr:ro
networks:
app-shared-net:
+5
View File
@@ -50,6 +50,11 @@ services:
- '--experimental.plugins.blockpath.moduleName=github.com/traefik/plugin-blockpath'
- '--experimental.plugins.blockpath.version=v0.2.1'
- '--ping=true'
- '--tracing.openTelemetry=true'
- '--tracing.openTelemetry.address=otel-collector:4318'
- '--tracing.openTelemetry.insecure=true'
- '--tracing.openTelemetry.grpc=false'
- '--tracing.serviceName=traefik'
healthcheck:
test: ['CMD', 'wget', '--spider', 'http://localhost:8080/ping']
interval: 30s
+5 -2
View File
@@ -5,8 +5,11 @@ metadata:
spec:
tracing:
samplingRate: "1"
zipkin:
endpointAddress: ""
stdout: false
otel:
endpointAddress: "otel-collector:4317"
isSecure: false
protocol: grpc
metrics:
enabled: true
mtls:
+383
View File
@@ -0,0 +1,383 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hub Dashboard</title>
<style>
:root {
--bg: #0d1117;
--bg-card: #161b22;
--bg-hover: #1c2333;
--border: #30363d;
--text: #e6edf3;
--text-dim: #8b949e;
--accent: #58a6ff;
--green: #3fb950;
--yellow: #d29922;
--red: #f85149;
--orange: #d4760b;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
padding: 20px;
line-height: 1.5;
}
.header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 24px; padding-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.header h1 { font-size: 24px; font-weight: 600; }
.header .subtitle { color: var(--text-dim); font-size: 13px; }
.header .status-bar { display: flex; gap: 16px; align-items: center; }
.header .status-bar .dot {
width: 10px; height: 10px; border-radius: 50%; display: inline-block;
}
.header .last-updated { color: var(--text-dim); font-size: 12px; }
.grid {
display: grid; gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px; padding: 16px;
}
.card h2 {
font-size: 14px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--text-dim); margin-bottom: 12px;
display: flex; justify-content: space-between; align-items: center;
}
.card h2 .count {
font-size: 12px; background: var(--bg-hover); padding: 2px 8px;
border-radius: 12px;
}
.service-list, .trace-list { list-style: none; }
.service-item {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 0; border-bottom: 1px solid var(--border);
font-size: 14px;
}
.service-item:last-child { border-bottom: none; }
.service-item .name { font-family: 'SF Mono', 'Fira Code', monospace; }
.service-item .badge {
font-size: 11px; padding: 2px 8px; border-radius: 12px;
font-weight: 500;
}
.badge-up { background: rgba(63,185,80,0.15); color: var(--green); }
.badge-down { background: rgba(248,81,73,0.15); color: var(--red); }
.badge-degraded { background: rgba(210,153,34,0.15); color: var(--yellow); }
.trace-item {
padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px;
}
.trace-item:last-child { border-bottom: none; }
.trace-item .trace-service { font-weight: 500; }
.trace-item .trace-op {
color: var(--text-dim); font-size: 12px;
font-family: 'SF Mono', 'Fira Code', monospace; margin: 2px 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.trace-item .trace-meta {
display: flex; gap: 12px; font-size: 11px; color: var(--text-dim);
}
.trace-item .trace-duration {
font-family: 'SF Mono', monospace; font-weight: 500;
}
.stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.stat-box {
background: var(--bg-hover); border-radius: 6px; padding: 12px; text-align: center;
}
.stat-value {
font-size: 28px; font-weight: 700; font-family: 'SF Mono', monospace;
}
.stat-label { font-size: 11px; color: var(--text-dim); margin-top: 4px; }
.links { display: flex; flex-wrap: wrap; gap: 8px; }
.links a {
color: var(--accent); text-decoration: none; font-size: 13px;
padding: 6px 12px; border: 1px solid var(--border); border-radius: 6px;
transition: all 0.15s;
}
.links a:hover { background: var(--bg-hover); border-color: var(--accent); }
.loading { text-align: center; padding: 20px; color: var(--text-dim); }
.inline-error {
background: rgba(248,81,73,0.1); border: 1px solid rgba(248,81,73,0.3);
border-radius: 6px; padding: 8px 12px; font-size: 12px;
color: var(--red); margin-bottom: 8px;
}
@media (max-width: 720px) {
.grid { grid-template-columns: 1fr; }
.header { flex-direction: column; align-items: flex-start; gap: 8px; }
}
</style>
</head>
<body>
<div class="header">
<div>
<h1>asepharyana-hub</h1>
<div class="subtitle">System Dashboard</div>
</div>
<div class="status-bar">
<span id="health-indicator" title="System Health"><span class="dot" style="background:var(--yellow)"></span> Loading...</span>
<span class="last-updated" id="last-updated">-</span>
</div>
</div>
<div class="grid">
<!-- Services -->
<div class="card">
<h2>Services <span class="count" id="service-count">0</span></h2>
<div id="service-list"><div class="loading">Loading...</div></div>
</div>
<!-- Stats -->
<div class="card">
<h2>Overview</h2>
<div class="stat-grid">
<div class="stat-box">
<div class="stat-value" id="stat-services">-</div>
<div class="stat-label">Services</div>
</div>
<div class="stat-box">
<div class="stat-value" id="stat-traces">-</div>
<div class="stat-label">Traces (5m)</div>
</div>
<div class="stat-box">
<div class="stat-value" id="stat-errors">-</div>
<div class="stat-label">Errors (5m)</div>
</div>
<div class="stat-box">
<div class="stat-value" id="stat-p99">-</div>
<div class="stat-label">P99 Latency</div>
</div>
</div>
</div>
<!-- Links -->
<div class="card">
<h2>Quick Links</h2>
<div class="links">
<a href="/jaeger" target="_blank">Jaeger UI</a>
<a href="/api/metrics" target="_blank">OTel Metrics</a>
<a href="https://traefik.asepharyana.my.id" target="_blank">Traefik</a>
<a href="https://scraper.asepharyana.my.id" target="_blank">Scraper API</a>
<a href="https://github.com/asepharyana/asepharyana-hub" target="_blank">GitHub</a>
</div>
</div>
<!-- Recent Traces -->
<div class="card" style="grid-column: 1 / -1;">
<h2>Recent Traces <span class="count" id="trace-count">0</span></h2>
<div id="trace-list"><div class="loading">Loading...</div></div>
</div>
</div>
<script>
const JAEGER_API = '/api/jaeger';
const TRACE_LIMIT = 20;
async function fetchJSON(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json();
}
function relativeTime(ms) {
const sec = Math.floor(ms / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
return `${hr}h ago`;
}
function formatDuration(us) {
if (us < 1000) return `${us}µs`;
if (us < 1_000_000) return `${(us / 1000).toFixed(1)}ms`;
return `${(us / 1_000_000).toFixed(2)}s`;
}
function nowMicro() {
return Date.now() * 1000;
}
function microToISO(us) {
return new Date(us / 1000).toISOString();
}
async function loadTraces() {
const el = document.getElementById('trace-list');
const countEl = document.getElementById('trace-count');
try {
const services = await fetchJSON(`${JAEGER_API}/api/services`);
let allTraces = [];
const lookback = 5 * 60 * 1_000_000; // 5 minutes in microseconds
const now = nowMicro();
const start = now - lookback;
for (const svc of services.data || []) {
try {
const data = await fetchJSON(
`${JAEGER_API}/api/traces?service=${encodeURIComponent(svc)}&start=${start}&end=${now}&limit=5&lookback=5m`
);
if (data.data) allTraces = allTraces.concat(data.data);
} catch(e) { /* skip service if error */ }
}
// Sort by start time descending
allTraces.sort((a, b) => (b.startTime || 0) - (a.startTime || 0));
allTraces = allTraces.slice(0, TRACE_LIMIT);
countEl.textContent = allTraces.length;
if (allTraces.length === 0) {
el.innerHTML = '<div style="color:var(--text-dim);font-size:13px;text-align:center;padding:12px;">No traces in the last 5 minutes</div>';
return;
}
el.innerHTML = allTraces.map(t => {
const duration = t.duration || 0;
const span = (t.spans && t.spans[0]) || {};
const proc = span.process || {};
const svcName = proc.serviceName || (t.processes && Object.values(t.processes)[0]?.serviceName) || 'unknown';
const opName = span.operationName || 'unknown';
const startTime = t.startTime ? microToISO(t.startTime) : '-';
const spanCount = (t.spans && t.spans.length) || 0;
const errSpan = t.spans && t.spans.find(s => (s.tags || []).some(tg => tg.key === 'error' && tg.value === true));
const errClass = errSpan ? 'color:var(--red)' : '';
return `<div class="trace-item">
<div class="trace-service" style="${errClass}">${escHtml(svcName)}</div>
<div class="trace-op">${escHtml(opName)}</div>
<div class="trace-meta">
<span class="trace-duration">${formatDuration(duration)}</span>
<span>${spanCount} spans</span>
<span>${relativeTime(Date.now() - new Date(startTime).getTime())}</span>
${errSpan ? '<span style="color:var(--red)">✗ error</span>' : ''}
</div>
</div>`;
}).join('');
} catch(e) {
el.innerHTML = `<div class="inline-error">Failed to load traces: ${escHtml(e.message)}</div>`;
}
}
function escHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
async function loadServices() {
const listEl = document.getElementById('service-list');
const countEl = document.getElementById('service-count');
const statSvc = document.getElementById('stat-services');
const services = [
{ name: 'traefik', port: 'traefik' },
{ name: 'redis', port: 'redis' },
{ name: 'nats', port: 'nats' },
{ name: 'dapr-placement', port: 'dapr-placement' },
{ name: 'scraper-api', port: 'scraper-api' },
{ name: 'scraper-api-dapr', port: 'scraper-api-dapr' },
{ name: 'otel-collector', port: 'otel-collector' },
{ name: 'jaeger', port: 'jaeger' },
];
// Try to get services from Jaeger for display
let jaegerServices = [];
try {
const svcRes = await fetchJSON(`${JAEGER_API}/api/services`);
jaegerServices = svcRes.data || [];
} catch(e) { /* ignore */ }
// Determine services to display: prefer Jaeger-reported + infrastructure
const allNames = new Set([...services.map(s => s.name), ...jaegerServices]);
const displayNames = Array.from(allNames).sort();
countEl.textContent = displayNames.length;
statSvc.textContent = displayNames.length;
listEl.innerHTML = displayNames.map(name => {
// Determine status: Jaeger services are "up", check others via probe
// Simple heuristic: green if in Docker services list, yellow for Jaeger-only
const infraService = services.find(s => s.name === name);
const status = infraService ? 'up' : 'degraded';
const badgeClass = status === 'up' ? 'badge-up' : 'badge-degraded';
const label = status === 'up' ? 'up' : 'unknown';
return `<div class="service-item">
<span class="name">${escHtml(name)}</span>
<span class="badge ${badgeClass}">${label}</span>
</div>`;
}).join('');
}
async function loadStats() {
try {
const now = nowMicro();
const lookback = 5 * 60 * 1_000_000;
const start = now - lookback;
const services = await fetchJSON(`${JAEGER_API}/api/services`);
let totalTraces = 0;
let totalErrors = 0;
let maxDuration = 0;
for (const svc of (services.data || []).slice(0, 5)) {
try {
const data = await fetchJSON(
`${JAEGER_API}/api/traces?service=${encodeURIComponent(svc)}&start=${start}&end=${now}&limit=50&lookback=5m`
);
if (data.data) {
totalTraces += data.data.length;
for (const t of data.data) {
if (t.duration > maxDuration) maxDuration = t.duration;
if (t.spans && t.spans.some(s => (s.tags || []).some(tg => tg.key === 'error' && tg.value === true))) {
totalErrors++;
}
}
}
} catch(e) { /* skip */ }
}
document.getElementById('stat-traces').textContent = totalTraces;
document.getElementById('stat-errors').textContent = totalErrors;
document.getElementById('stat-p99').textContent = maxDuration > 0 ? formatDuration(maxDuration) : '-';
} catch(e) { /* ignore - stats may not load */ }
}
function setHealth(status, label) {
const el = document.getElementById('health-indicator');
el.innerHTML = `<span class="dot" style="background:var(--${status})"></span> ${label}`;
}
async function refresh() {
try {
await Promise.all([
loadTraces(),
loadServices(),
loadStats(),
]);
setHealth('green', 'All Systems Operational');
} catch(e) {
setHealth('red', 'Dashboard Error');
}
document.getElementById('last-updated').textContent = `Updated ${new Date().toLocaleTimeString()}`;
}
// Initial load
refresh();
// Auto-refresh every 15s
setInterval(refresh, 15000);
</script>
</body>
</html>
+70
View File
@@ -0,0 +1,70 @@
server {
listen 8080;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# CORS headers for API endpoints
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, OPTIONS";
add_header Access-Control-Allow-Headers "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range";
# Security headers
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header Referrer-Policy same-origin;
# Gzip
gzip on;
gzip_types text/html text/css application/javascript application/json;
# Jaeger API proxy
location /api/jaeger/ {
proxy_pass http://jaeger:16686/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_http_version 1.1;
proxy_read_timeout 30s;
}
# Jaeger UI
location /jaeger/ {
proxy_pass http://jaeger:16686/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400s;
}
# OTel collector prometheus metrics
location /api/metrics {
proxy_pass http://otel-collector:8889/metrics;
proxy_set_header Host $host;
proxy_read_timeout 10s;
}
# OTel collector health
location /api/health {
proxy_pass http://otel-collector:8888/healthz;
proxy_set_header Host $host;
proxy_read_timeout 5s;
}
# Static files (including index.html)
location / {
try_files $uri $uri/ /index.html;
expires 5s;
add_header Cache-Control "public, must-revalidate";
}
# Deny hidden files
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
error_page 404 /index.html;
}
+5
View File
@@ -0,0 +1,5 @@
FROM nginx:alpine
COPY infra/dashboard/nginx.conf /etc/nginx/conf.d/default.conf
COPY infra/dashboard/index.html /usr/share/nginx/html/index.html
EXPOSE 8080
CMD ["nginx", "-g", "daemon off;"]
+19
View File
@@ -0,0 +1,19 @@
# ── NATS Server Configuration ──
# JetStream
jetstream: true
store_dir: /data
# OpenTelemetry tracing
otel:
traces:
exporter: otlp
otlp:
endpoint: otel-collector:4318
insecure: true
# HTTP monitoring
http_port: 8222
# Limits
max_payload: 1MB
max_pending_size: 64MB
+51
View File
@@ -0,0 +1,51 @@
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
exporters:
otlp/jaeger:
endpoint: jaeger:4317
tls:
insecure: true
prometheus:
endpoint: 0.0.0.0:8889
enable_open_metrics: true
resource_to_telemetry_conversion:
enabled: true
debug:
verbosity: normal
sampling_initial: 5
sampling_thereafter: 100
processors:
batch:
timeout: 1s
send_batch_size: 1024
memory_limiter:
check_interval: 1s
limit_mib: 512
spike_limit_mib: 128
attributes:
actions:
- key: service.namespace
value: asepharyana-hub
action: upsert
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch, attributes]
exporters: [otlp/jaeger, debug]
metrics:
receivers: [otlp]
processors: [memory_limiter, batch, attributes]
exporters: [prometheus, debug]
+30
View File
@@ -10,8 +10,38 @@ http:
- common-chain@file
service: scraper-service
# ── Dashboard ──
dashboard:
rule: 'Host(`dashboard.asepharyana.my.id`) || Host(`status.asepharyana.my.id`) || Host(`dashboard.asepharyana.web.id`) || Host(`status.asepharyana.web.id`)'
entryPoints:
- websecure
tls: {}
middlewares:
- common-chain@file
service: dashboard-service
# ── Jaeger UI ──
jaeger:
rule: 'Host(`jaeger.asepharyana.my.id`) || Host(`jaeger.asepharyana.web.id`)'
entryPoints:
- websecure
tls: {}
middlewares:
- common-chain@file
service: jaeger-service
services:
scraper-service:
loadBalancer:
servers:
- url: 'http://scraper-api:4091'
dashboard-service:
loadBalancer:
servers:
- url: 'http://dashboard:8080'
jaeger-service:
loadBalancer:
servers:
- url: 'http://jaeger:16686'