Commit 0377fcaa authored by Umar's avatar Umar

init: chat-opentel workspace with OTel observability

parents
Pipeline #2159 failed with stages
node_modules
dist
frontend
.git
*.md
docker-compose.yml
Dockerfile*
observability
# Node / TS
node_modules/
dist/
# IDE
.vscode/
.idea/
# Env / secrets
.env
.env.*
!env.example
# Observability runtime data
observability/tempo-data/**
!observability/tempo-data/.gitkeep
observability/grafana/data/
# Docker
.docker-compose.yml.swp
*.pid
# ── Build stage ───────────────────────────────────────────────────────────────
FROM node:20-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build
# ── Runtime stage ─────────────────────────────────────────────────────────────
FROM node:20-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm install --omit=dev
COPY --from=builder /app/dist ./dist
# Load tracing before the app so auto-instrumentations patch modules first
CMD ["node", "--import", "./dist/tracing.js", "dist/server.js"]
# ─────────────────────────────────────────────────────────────────────────────
# Hono Chat App + OpenTelemetry → Grafana Tempo → Grafana
#
# Start everything: docker compose up --build
# Chat UI: http://localhost:5173
# Grafana: http://localhost:3001 (admin / admin)
# Tempo (raw): http://localhost:3200
# ─────────────────────────────────────────────────────────────────────────────
services:
# ── Hono WebSocket chat server ─────────────────────────────────────────────
chat-server:
build:
context: .
dockerfile: Dockerfile.server
ports:
- "3000:3000"
environment:
NODE_ENV: production
PORT: "3000"
OTEL_SERVICE_NAME: hono-chat-app
SERVICE_VERSION: "1.0.0"
# Point OTel exporter at Tempo inside the compose network
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4318/v1/traces
depends_on:
tempo:
condition: service_healthy
restart: unless-stopped
# ── React frontend (served by Vite in dev, nginx in prod) ─────────────────
chat-frontend:
build:
context: ./frontend
dockerfile: Dockerfile.frontend
ports:
- "5173:80"
environment:
# Vite replaces these at build-time; for runtime nginx proxy see nginx.conf
VITE_WS_URL: ws://localhost:3000/ws
VITE_API_URL: http://localhost:3000
depends_on:
- chat-server
restart: unless-stopped
# ── Grafana Tempo (trace backend, OTLP receiver) ───────────────────────────
tempo:
image: grafana/tempo:2.6.0
command: ["-config.file=/etc/tempo.yaml"]
volumes:
- ./observability/tempo.yaml:/etc/tempo.yaml:ro
- ./observability/tempo-data:/tmp/tempo
ports:
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
- "3200:3200" # Tempo HTTP API
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3200/ready"]
interval: 5s
timeout: 5s
retries: 10
start_period: 15s
restart: unless-stopped
# ── Prometheus (stores span-metrics generated by Tempo) ───────────────────
prometheus:
image: prom/prometheus:v2.54.0
command:
- --config.file=/etc/prometheus/prometheus.yml
- --web.enable-remote-write-receiver # Tempo pushes span-metrics here
- --enable-feature=exemplar-storage
volumes:
- ./observability/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus-data:/prometheus
ports:
- "9090:9090"
restart: unless-stopped
# ── Grafana (dashboards) ───────────────────────────────────────────────────
grafana:
image: grafana/grafana:11.2.0
ports:
- "3001:3000"
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: admin
GF_AUTH_ANONYMOUS_ENABLED: "true"
GF_AUTH_ANONYMOUS_ORG_ROLE: Viewer
GF_FEATURE_TOGGLES_ENABLE: traceqlEditor traceToMetrics
volumes:
- ./observability/grafana/provisioning:/etc/grafana/provisioning:ro
- grafana-data:/var/lib/grafana
depends_on:
- tempo
- prometheus
restart: unless-stopped
volumes:
prometheus-data:
grafana-data:
# ── Build stage ───────────────────────────────────────────────────────────────
FROM node:20-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# ── Runtime stage (nginx) ─────────────────────────────────────────────────────
FROM nginx:alpine AS runtime
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hono Chat + OTel</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Proxy /messages and /health to the Hono backend
location ~ ^/(messages|health) {
proxy_pass http://chat-server:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Proxy WebSocket upgrades
location /ws {
proxy_pass http://chat-server:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
}
{
"name": "chat-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.6.0",
"vite": "^5.4.0"
}
}
import { useState, useEffect, useRef, FormEvent, useCallback } from "react";
// ─── Types (mirrored from server/src/types.ts) ──────────────────────────────
type Message = {
id: number;
date: string;
userId: string;
text: string;
};
const publishActions = {
UPDATE_CHAT: "UPDATE_CHAT",
DELETE_CHAT: "DELETE_CHAT",
} as const;
type DataToSend = {
action: keyof typeof publishActions;
message: Message;
};
// ─── Helpers ─────────────────────────────────────────────────────────────────
function randomUserId() {
return "User-" + Math.random().toString(36).slice(2, 7).toUpperCase();
}
// ─── URLs — in Docker, nginx proxies /ws and /messages to chat-server ────────
// So we always use the current browser host (works both locally and in Docker)
const WS_URL = `ws://${window.location.host}/ws`;
const API_URL = ""; // relative — proxied by nginx or Vite dev server
// ─── App ─────────────────────────────────────────────────────────────────────
export default function App() {
const [messages, setMessages] = useState<Message[]>([]);
const [text, setText] = useState("");
const [userId] = useState(randomUserId);
const [connected, setConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
useEffect(() => {
let socket: WebSocket;
let reconnectTimer: ReturnType<typeof setTimeout>;
function connect() {
socket = new WebSocket(WS_URL);
socket.onopen = () => {
setConnected(true);
setError(null);
};
socket.onmessage = (evt) => {
try {
const data: DataToSend = JSON.parse(evt.data);
if (data.action === publishActions.UPDATE_CHAT) {
setMessages((prev) => {
if (prev.some((m) => m.id === data.message.id)) return prev;
return [...prev, data.message];
});
} else if (data.action === publishActions.DELETE_CHAT) {
setMessages((prev) => prev.filter((m) => m.id !== data.message.id));
}
} catch {
console.warn("[WS] Unknown message", evt.data);
}
};
socket.onclose = () => {
setConnected(false);
reconnectTimer = setTimeout(connect, 2000);
};
socket.onerror = () => {
setError("WebSocket error — reconnecting…");
};
}
connect();
return () => {
clearTimeout(reconnectTimer);
socket?.close();
};
}, []);
const handleSubmit = useCallback(
async (e: FormEvent) => {
e.preventDefault();
const trimmed = text.trim();
if (!trimmed) return;
try {
const res = await fetch(`${API_URL}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, text: trimmed }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setText("");
} catch (err) {
setError(`Send failed: ${(err as Error).message}`);
}
},
[text, userId]
);
const handleDelete = useCallback(async (id: number) => {
await fetch(`${API_URL}/messages/${id}`, { method: "DELETE" });
}, []);
return (
<div style={styles.root}>
<header style={styles.header}>
<div style={styles.headerLeft}>
<span style={styles.logo}>💬</span>
<span style={styles.title}>Hono Chat</span>
<span
style={{
...styles.badge,
background: connected ? "#22c55e22" : "#ef444422",
color: connected ? "#4ade80" : "#f87171",
border: `1px solid ${connected ? "#4ade8066" : "#f8717166"}`,
}}
>
{connected ? "● Live" : "○ Reconnecting…"}
</span>
</div>
<a href="http://localhost:3001" target="_blank" rel="noreferrer" style={styles.grafanaLink}>
📊 Grafana
</a>
</header>
{error && (
<div style={styles.errorBanner}>
{error}
<button style={styles.dismissBtn} onClick={() => setError(null)}></button>
</div>
)}
<main style={styles.main}>
{messages.length === 0 ? (
<div style={styles.empty}>No messages yet. Be the first to say hello!</div>
) : (
messages.map((msg) => {
const isMine = msg.userId === userId;
return (
<div key={msg.id} style={{ ...styles.messageRow, justifyContent: isMine ? "flex-end" : "flex-start" }}>
<div
style={{
...styles.bubble,
background: isMine ? "#3b82f6" : "#1e293b",
borderBottomRightRadius: isMine ? 4 : 18,
borderBottomLeftRadius: isMine ? 18 : 4,
}}
>
{!isMine && <div style={styles.sender}>{msg.userId}</div>}
<div style={styles.msgText}>{msg.text}</div>
<div style={styles.meta}>
{msg.date}
{isMine && (
<button style={styles.delBtn} title="Delete" onClick={() => handleDelete(msg.id)}></button>
)}
</div>
</div>
</div>
);
})
)}
<div ref={bottomRef} />
</main>
<form style={styles.form} onSubmit={handleSubmit}>
<span style={styles.userChip}>{userId}</span>
<input
style={styles.input}
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Type a message…"
disabled={!connected}
autoFocus
/>
<button
style={{ ...styles.sendBtn, opacity: connected && text.trim() ? 1 : 0.5 }}
type="submit"
disabled={!connected || !text.trim()}
>
Send
</button>
</form>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
root: { display: "flex", flexDirection: "column", height: "100%", maxWidth: 780, margin: "0 auto", width: "100%" },
header: { display: "flex", alignItems: "center", justifyContent: "space-between", padding: "12px 16px", borderBottom: "1px solid #1e293b", background: "#0d1117", flexShrink: 0 },
headerLeft: { display: "flex", alignItems: "center", gap: 10 },
logo: { fontSize: 22 },
title: { fontWeight: 700, fontSize: 18, color: "#f1f5f9" },
badge: { fontSize: 12, padding: "2px 10px", borderRadius: 99, fontWeight: 600 },
grafanaLink: { fontSize: 13, color: "#94a3b8", textDecoration: "none", padding: "4px 12px", borderRadius: 8, border: "1px solid #1e293b", background: "#0f172a" },
errorBanner: { display: "flex", alignItems: "center", justifyContent: "space-between", background: "#7f1d1d", color: "#fecaca", padding: "8px 16px", fontSize: 13, flexShrink: 0 },
dismissBtn: { background: "none", border: "none", color: "#fecaca", cursor: "pointer", fontSize: 14 },
main: { flex: 1, overflowY: "auto", padding: "16px 12px", display: "flex", flexDirection: "column", gap: 10 },
empty: { margin: "auto", color: "#475569", fontSize: 15, textAlign: "center", padding: 40 },
messageRow: { display: "flex" },
bubble: { maxWidth: "70%", padding: "10px 14px", borderRadius: 18, fontSize: 14, lineHeight: 1.5 },
sender: { fontSize: 11, color: "#94a3b8", marginBottom: 4, fontWeight: 600 },
msgText: { wordBreak: "break-word" },
meta: { display: "flex", alignItems: "center", gap: 8, marginTop: 4, fontSize: 11, color: "#64748b" },
delBtn: { background: "none", border: "none", color: "#64748b", cursor: "pointer", fontSize: 12, padding: 0, lineHeight: 1 },
form: { display: "flex", alignItems: "center", gap: 8, padding: "12px 16px", borderTop: "1px solid #1e293b", background: "#0d1117", flexShrink: 0 },
userChip: { fontSize: 11, color: "#60a5fa", background: "#172554", padding: "4px 10px", borderRadius: 99, whiteSpace: "nowrap", fontWeight: 600 },
input: { flex: 1, padding: "10px 14px", background: "#1e293b", border: "1px solid #334155", borderRadius: 10, color: "#f1f5f9", fontSize: 14, outline: "none" },
sendBtn: { padding: "10px 20px", background: "#3b82f6", color: "#fff", border: "none", borderRadius: 10, fontWeight: 600, fontSize: 14, cursor: "pointer", whiteSpace: "nowrap" },
};
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: #0f1117;
color: #e2e8f0;
height: 100dvh;
display: flex;
flex-direction: column;
}
#root {
height: 100%;
display: flex;
flex-direction: column;
}
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/messages": {
target: "http://localhost:3000",
changeOrigin: true,
},
"/ws": {
target: "ws://localhost:3000",
ws: true,
changeOrigin: true,
},
},
},
});
{
"uid": "chat-otel-dashboard",
"title": "Hono Chat — Traces & Traffic",
"tags": ["chat", "otel", "hono"],
"timezone": "browser",
"schemaVersion": 38,
"refresh": "5s",
"time": { "from": "now-15m", "to": "now" },
"panels": [
{
"id": 1,
"title": "Request Rate (spans/min)",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"datasource": { "uid": "prometheus", "type": "prometheus" },
"targets": [
{
"expr": "rate(traces_spanmetrics_calls_total{service_name=\"hono-chat-app\"}[1m]) * 60",
"legendFormat": "{{span_name}}",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"unit": "reqpm",
"custom": { "lineWidth": 2, "fillOpacity": 10 }
}
}
},
{
"id": 2,
"title": "Span Duration — p50 / p95 / p99",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"datasource": { "uid": "prometheus", "type": "prometheus" },
"targets": [
{
"expr": "histogram_quantile(0.50, rate(traces_spanmetrics_duration_milliseconds_bucket{service_name=\"hono-chat-app\"}[2m]))",
"legendFormat": "p50",
"refId": "A"
},
{
"expr": "histogram_quantile(0.95, rate(traces_spanmetrics_duration_milliseconds_bucket{service_name=\"hono-chat-app\"}[2m]))",
"legendFormat": "p95",
"refId": "B"
},
{
"expr": "histogram_quantile(0.99, rate(traces_spanmetrics_duration_milliseconds_bucket{service_name=\"hono-chat-app\"}[2m]))",
"legendFormat": "p99",
"refId": "C"
}
],
"fieldConfig": {
"defaults": {
"unit": "ms",
"custom": { "lineWidth": 2, "fillOpacity": 5 }
}
}
},
{
"id": 3,
"title": "Error Rate",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
"datasource": { "uid": "prometheus", "type": "prometheus" },
"targets": [
{
"expr": "rate(traces_spanmetrics_calls_total{service_name=\"hono-chat-app\", status_code=\"STATUS_CODE_ERROR\"}[1m]) * 60",
"legendFormat": "Errors/min — {{span_name}}",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "reqpm",
"color": { "mode": "fixed", "fixedColor": "red" },
"custom": { "lineWidth": 2, "fillOpacity": 15 }
}
}
},
{
"id": 4,
"title": "Span Calls by Operation",
"type": "piechart",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
"datasource": { "uid": "prometheus", "type": "prometheus" },
"targets": [
{
"expr": "sum by (span_name) (increase(traces_spanmetrics_calls_total{service_name=\"hono-chat-app\"}[15m]))",
"legendFormat": "{{span_name}}",
"refId": "A"
}
],
"options": { "pieType": "donut", "displayLabels": ["name", "percent"] }
},
{
"id": 5,
"title": "Service Graph — Span Topology",
"type": "nodeGraph",
"gridPos": { "h": 10, "w": 24, "x": 0, "y": 16 },
"datasource": { "uid": "tempo", "type": "tempo" },
"targets": [
{
"queryType": "serviceMap",
"refId": "A"
}
]
},
{
"id": 6,
"title": "Trace Search — click any row to open trace",
"type": "table",
"gridPos": { "h": 10, "w": 24, "x": 0, "y": 26 },
"datasource": { "uid": "tempo", "type": "tempo" },
"targets": [
{
"queryType": "traceql",
"query": "{resource.service.name=\"hono-chat-app\"}",
"limit": 20,
"refId": "A"
}
],
"options": {
"footer": { "show": false }
}
}
]
}
apiVersion: 1
providers:
- name: "Chat App Dashboards"
orgId: 1
folder: "Chat App"
type: file
disableDeletion: false
editable: true
options:
path: /etc/grafana/provisioning/dashboards
apiVersion: 1
datasources:
- name: Tempo
type: tempo
uid: tempo
access: proxy
url: http://tempo:3200
isDefault: false
jsonData:
httpMethod: GET
serviceMap:
datasourceUid: prometheus
nodeGraph:
enabled: true
search:
hide: false
lokiSearch:
datasourceUid: loki
traceQuery:
timeShiftEnabled: true
spanStartTimeShift: "1h"
spanEndTimeShift: "-1h"
spanBar:
type: "Tag"
tag: "http.route"
- name: Prometheus
type: prometheus
uid: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
jsonData:
exemplarTraceIdDestinations:
- name: traceID
datasourceUid: tempo
global:
scrape_interval: 15s
evaluation_interval: 15s
# Enable remote-write receiver (so Tempo can push metrics here)
# This is a command-line flag, not config — handled in docker-compose
scrape_configs:
- job_name: prometheus
static_configs:
- targets: ["localhost:9090"]
- job_name: chat-server
static_configs:
- targets: ["chat-server:3000"]
metrics_path: /metrics
# If you add prom metrics later they appear here automatically
- job_name: tempo
static_configs:
- targets: ["tempo:3200"]
server:
http_listen_port: 3200
distributor:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
ingester:
max_block_duration: 5m
compactor:
compaction:
block_retention: 1h
storage:
trace:
backend: local
local:
path: /tmp/tempo/blocks
wal:
path: /tmp/tempo/wal
metrics_generator:
registry:
external_labels:
source: tempo
cluster: docker-compose
storage:
path: /tmp/tempo/generator/wal
remote_write:
- url: http://prometheus:9090/api/v1/write
send_exemplars: true
overrides:
defaults:
metrics_generator:
processors: [service-graphs, span-metrics]
{
"name": "chat-otel",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "node --import ./dist/tracing.js dist/server.js",
"dev:ts": "tsx --import ./src/tracing.ts src/server.ts",
"build": "tsc",
"start": "node --import ./dist/tracing.js dist/server.js"
},
"dependencies": {
"@hono/node-server": "^1.12.0",
"@hono/zod-validator": "^0.4.3",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.56.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.57.0",
"@opentelemetry/resources": "^1.28.0",
"@opentelemetry/sdk-node": "^0.57.0",
"@opentelemetry/semantic-conventions": "^1.28.0",
"hono": "^4.6.0",
"ws": "^8.18.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/ws": "^8.5.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
}
}
\ No newline at end of file
// src/otel-middleware.ts
import { trace, SpanStatusCode, SpanKind } from "@opentelemetry/api";
import type { MiddlewareHandler } from "hono";
const tracer = trace.getTracer("hono-otel-middleware", "1.0.0");
/**
* Hono middleware that wraps each incoming HTTP request in an OTel server span.
* The span becomes the parent for all child spans (DB calls, fetch, custom spans)
* created inside the handler via `startActiveSpan`.
*/
export const otelMiddleware = (): MiddlewareHandler =>
async (c, next) => {
// Use matched route pattern as span name (avoids high-cardinality span names)
const route = c.req.routePath ?? c.req.path;
const spanName = `${c.req.method} ${route}`;
await tracer.startActiveSpan(
spanName,
{ kind: SpanKind.SERVER },
async (span) => {
span.setAttribute("http.request.method", c.req.method);
span.setAttribute("url.path", c.req.path);
span.setAttribute("http.route", route);
span.setAttribute(
"user_agent",
c.req.header("user-agent") ?? "unknown"
);
try {
await next();
const status = c.res.status;
span.setAttribute("http.response.status_code", status);
if (status >= 500) {
span.setStatus({ code: SpanStatusCode.ERROR });
} else {
span.setStatus({ code: SpanStatusCode.OK });
}
} catch (err) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: (err as Error).message,
});
span.recordException(err as Error);
throw err;
} finally {
span.end();
}
}
);
};
// src/server.ts
// NOTE: tracing.ts is loaded BEFORE this file via --import flag.
// Do NOT import tracing here — it must run before any other module.
import { Hono } from "hono";
import { cors } from "hono/cors";
import { logger } from "hono/logger";
import { serve } from "@hono/node-server";
import { zValidator } from "@hono/zod-validator";
import { trace, SpanStatusCode } from "@opentelemetry/api";
import { otelMiddleware } from "./otel-middleware.js";
import {
MessageFormSchema,
publishActions,
type Message,
type DataToSend,
} from "./types.js";
import { WebSocketServer, WebSocket } from "ws";
import type { IncomingMessage } from "http";
import type { Server } from "http";
// ─── App setup ────────────────────────────────────────────────────────────────
const app = new Hono();
// ─── OTel tracer for business-level spans ─────────────────────────────────────
const tracer = trace.getTracer("chat-server", "1.0.0");
// ─── Global middleware ────────────────────────────────────────────────────────
app.use("*", logger());
app.use("*", cors({ origin: "*" }));
app.use("*", otelMiddleware());
// ─── In-memory state ──────────────────────────────────────────────────────────
const messages: Message[] = [];
const clients = new Set<WebSocket>();
let totalConnections = 0;
let totalMessages = 0;
function broadcast(payload: DataToSend) {
const json = JSON.stringify(payload);
clients.forEach((ws) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(json);
}
});
}
// ─── REST: GET /messages ──────────────────────────────────────────────────────
app.get("/messages", (c) => {
return tracer.startActiveSpan("chat.messages.list", (span) => {
span.setAttribute("chat.message_count", messages.length);
span.end();
return c.json(messages);
});
});
// ─── REST: POST /messages ─────────────────────────────────────────────────────
app.post(
"/messages",
zValidator("json", MessageFormSchema, (result, c) => {
if (!result.success) {
return c.json({ ok: false, error: result.error.flatten() }, 400);
}
}),
async (c) => {
return tracer.startActiveSpan("chat.message.publish", async (span) => {
try {
const param = c.req.valid("json");
totalMessages++;
const now = new Date();
const message: Message = {
id: now.getTime(),
date: now.toLocaleString(),
...param,
};
span.setAttribute("chat.message.user_id", param.userId);
span.setAttribute("chat.message.text_length", param.text.length);
span.setAttribute("chat.message.id", message.id);
span.setAttribute("chat.total_messages", totalMessages);
span.setAttribute("chat.active_clients", clients.size);
messages.push(message);
broadcast({ action: publishActions.UPDATE_CHAT, message });
span.setStatus({ code: SpanStatusCode.OK });
return c.json({ ok: true, id: message.id });
} catch (err) {
span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message });
span.recordException(err as Error);
throw err;
} finally {
span.end();
}
});
}
);
// ─── REST: DELETE /messages/:id ───────────────────────────────────────────────
app.delete("/messages/:id", (c) => {
return tracer.startActiveSpan("chat.message.delete", (span) => {
const id = Number(c.req.param("id"));
span.setAttribute("chat.message.id", id);
const idx = messages.findIndex((m) => m.id === id);
if (idx === -1) {
span.setStatus({ code: SpanStatusCode.ERROR, message: "not found" });
span.end();
return c.json({ ok: false, error: "not found" }, 404);
}
const [removed] = messages.splice(idx, 1);
broadcast({ action: publishActions.DELETE_CHAT, message: removed });
span.setAttribute("chat.messages_remaining", messages.length);
span.setStatus({ code: SpanStatusCode.OK });
span.end();
return c.json({ ok: true });
});
});
// ─── Health endpoint ──────────────────────────────────────────────────────────
app.get("/health", (c) => {
return c.json({
status: "ok",
stats: {
activeClients: clients.size,
totalConnections,
totalMessages,
storedMessages: messages.length,
},
});
});
// ─── Start HTTP server ────────────────────────────────────────────────────────
const PORT = Number(process.env.PORT ?? 3000);
const server = serve({ fetch: app.fetch, port: PORT }, (info) => {
console.log(`[Server] Listening on http://localhost:${info.port}`);
console.log(`[Server] WebSocket at ws://localhost:${info.port}/ws`);
}) as unknown as Server;
// ─── Attach WebSocket server to the same HTTP server ─────────────────────────
const wss = new WebSocketServer({ server, path: "/ws" });
wss.on("connection", (ws: WebSocket, _req: IncomingMessage) => {
totalConnections++;
const connId = totalConnections;
clients.add(ws);
tracer.startActiveSpan("ws.connection.open", (span) => {
span.setAttribute("ws.connection_id", connId);
span.setAttribute("ws.active_clients", clients.size);
span.setAttribute("ws.total_connections", totalConnections);
span.setStatus({ code: SpanStatusCode.OK });
span.end();
});
console.log(`[WS] Client #${connId} connected. Active: ${clients.size}`);
// Replay history to new client
messages.forEach((msg) => {
ws.send(JSON.stringify({ action: publishActions.UPDATE_CHAT, message: msg }));
});
ws.on("close", () => {
clients.delete(ws);
tracer.startActiveSpan("ws.connection.close", (span) => {
span.setAttribute("ws.remaining_clients", clients.size);
span.end();
});
console.log(`[WS] Client disconnected. Active: ${clients.size}`);
});
ws.on("error", (err: Error) => {
tracer.startActiveSpan("ws.error", (span) => {
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
span.recordException(err);
span.end();
});
});
});
export type AppType = typeof app;
export default app;
// src/tracing.ts
// This file MUST be loaded before any other module.
// It bootstraps the OTel SDK, wires the OTLP exporter pointing at
// Grafana Tempo, and registers auto-instrumentations.
import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { Resource } from "@opentelemetry/resources";
import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
} from "@opentelemetry/semantic-conventions";
// deployment.environment.name is still incubating
const ATTR_DEPLOYMENT_ENVIRONMENT_NAME = "deployment.environment.name";
const otlpEndpoint =
process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "http://localhost:4318/v1/traces";
const exporter = new OTLPTraceExporter({ url: otlpEndpoint });
const sdk = new NodeSDK({
resource: new Resource({
[ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME ?? "hono-chat-app",
[ATTR_SERVICE_VERSION]: process.env.SERVICE_VERSION ?? "1.0.0",
[ATTR_DEPLOYMENT_ENVIRONMENT_NAME]: process.env.NODE_ENV ?? "development",
}),
traceExporter: exporter,
instrumentations: [
getNodeAutoInstrumentations({
// fs instrumentation is very noisy — disable it
"@opentelemetry/instrumentation-fs": { enabled: false },
}),
],
});
sdk.start();
console.log(
`[OTel] SDK started — exporting traces to ${otlpEndpoint}`
);
// Flush buffered spans on shutdown so the last batch isn't lost
process.on("SIGTERM", () => {
sdk
.shutdown()
.catch((err) => console.error("[OTel] shutdown error", err))
.finally(() => process.exit(0));
});
// shared/types.ts
import { z } from "zod";
export const MessageFormSchema = z.object({
userId: z.string().min(1),
text: z.string().trim().min(1),
});
export type MessageFormValues = z.infer<typeof MessageFormSchema>;
export type Message = {
id: number;
date: string;
} & MessageFormValues;
export const publishActions = {
UPDATE_CHAT: "UPDATE_CHAT",
DELETE_CHAT: "DELETE_CHAT",
} as const;
export type PublishAction = (typeof publishActions)[keyof typeof publishActions];
export type DataToSend = {
action: PublishAction;
message: Message;
};
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "frontend"]
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment