Expand Docker Compose development stack to include observability tools: OpenTelemetry Collector, Jaeger, Prometheus, and Grafana. Update README with new usage instructions for the observability stack and enhance Taskfile for streamlined development commands. Introduce sample telegram generation utility and improve NATS configuration for core and JetStream modes.

This commit is contained in:
windyboy
2025-11-15 12:08:26 +08:00
parent 4f16bd6d90
commit 184c5453fb
13 changed files with 619 additions and 43 deletions
+14 -13
View File
@@ -225,27 +225,28 @@ Critical overrides stay available through CLI flags; advanced tuning such as str
## Development ## Development
### Docker Compose Dev Stack See `docs/dev-guide.md` for the full development workflow, including Docker Compose instructions, observability tooling, and troubleshooting tips.
For a local stack running TimescaleDB + NATS (matching `config.dev.toml`), use `docker-compose.dev.yml`: Quick start:
```bash ```bash
docker compose -f docker-compose.dev.yml up -d postgres nats # Start database + messaging
docker compose -f docker-compose.dev.yml up app docker compose -f docker-compose.dev.yml up -d postgres nats nats-box
# Start observability stack (optional)
docker compose -f docker-compose.dev.yml up -d otel-collector jaeger prometheus grafana
``` ```
- `postgres` uses TimescaleDB, seeding the `aviation` schema via `internal/repository/telegrams.ddl` (extension + hypertable). Run the processor locally while the infra runs in Docker (dev config defaults to `nats.mode = "core"` so the consumer reads from plain NATS subjects):
- `app` mounts the repo so code changes are picked up by `go run ./cmd/main listen`.
- `nats` exposes 4222 (client) and 8222 (monitoring); `nats-box` is available for JetStream inspection (`docker compose exec nats-box sh`).
Prefer to run the Go binary on your host for quicker iteration:
```bash ```bash
docker compose -f docker-compose.dev.yml up -d postgres nats GO_ENV=dev \
GO_ENV=dev CAATSM_POSTGRES_URL=postgres://caatsm:caatsm@localhost:5432/aviation?sslmode=disable go run ./cmd/main listen CAATSM_NATS_MODE=core \
CAATSM_POSTGRES_URL=postgres://caatsm:caatsm@localhost:5432/aviation?sslmode=disable \
go run ./cmd/main listen
``` ```
Bring everything down with `docker compose -f docker-compose.dev.yml down -v` when finished. Tear everything down with `docker compose -f docker-compose.dev.yml down -v`.
### Project Structure ### Project Structure
@@ -286,7 +287,7 @@ ginkgo -r
## Message Flow ## Message Flow
1. **NATS Consumer** receives raw telegram messages from JetStream 1. **NATS Consumer** receives raw telegram messages from NATS (JetStream durable pull in production; plain `nc.Subscribe` in dev when `nats.mode=core`)
2. **MessageProcessor** orchestrates the processing: 2. **MessageProcessor** orchestrates the processing:
- Parses the message using the Parser adapter - Parses the message using the Parser adapter
- Stores the parsed message in PostgreSQL via Repository - Stores the parsed message in PostgreSQL via Repository
+46 -17
View File
@@ -32,13 +32,6 @@ tasks:
cmds: cmds:
- task: run-dev - task: run-dev
run-dev:
desc: Run the receiver in development mode
cmds:
- task: build-receiver
- echo "Running receiver in development mode..."
- GO_ENV=dev {{.build_dir}}/receiver listen
run-prod: run-prod:
desc: Run the receiver in production mode desc: Run the receiver in production mode
cmds: cmds:
@@ -104,32 +97,68 @@ tasks:
- echo "Linting code..." - echo "Linting code..."
- golangci-lint run - golangci-lint run
run-dev:
desc: Run the receiver in development mode (binary)
cmds:
- task: build-receiver
- echo "Running receiver in development mode..."
- GO_ENV=dev {{.build_dir}}/receiver listen
up: up:
desc: Start TimescaleDB + NATS dev stack desc: Start TimescaleDB + NATS dev stack (docker compose)
cmds: cmds:
- echo "Starting dev infrastructure..." - echo "Starting dev infrastructure..."
- podman compose -f docker-compose.dev.yml up -d - podman compose -f docker-compose.dev.yml up -d postgres nats nats-box
- podman compose -f docker-compose.dev.yml up -d otel-collector jaeger prometheus grafana
down: down:
desc: Stop dev compose stack and remove containers desc: Stop dev compose stack and remove containers
cmds: cmds:
- echo "Stopping dev infrastructure..." - echo "Stopping dev infrastructure..."
- podman compose -f docker-compose.dev.yml down - podman compose -f docker-compose.dev.yml down -v
dev-run: dev-run:
desc: Run receiver locally against dev stack desc: Run receiver locally against dev stack
deps: deps:
- up - up
env:
CAATSM_NATS_URL: nats://localhost:4222
CAATSM_NATS_MODE: jetstream
CAATSM_POSTGRES_URL: postgres://caatsm:caatsm@localhost:5432/aviation?sslmode=disable
CAATSM_TELEMETRY_ENABLED: "true"
CAATSM_TELEMETRY_ENDPOINT: localhost:4318
CAATSM_TELEMETRY_INSECURE: "true"
GO_ENV: dev
cmds: cmds:
- > - |
CAATSM_NATS_URL=nats://localhost:4222 echo "Running receiver with telemetry (mode=${CAATSM_NATS_MODE:-core})"
CAATSM_POSTGRES_URL=postgres://caatsm:caatsm@localhost:5432/aviation?sslmode=disable CAATSM_NATS_MODE=${CAATSM_NATS_MODE:-core} \
CAATSM_TELEMETRY_ENABLED=true
CAATSM_TELEMETRY_ENDPOINT=localhost:4318
GO_ENV=dev
go run ./cmd/main listen go run ./cmd/main listen
seed:
desc: Generate sample telegrams (publish to NATS)
cmds:
- |
GO_ENV=dev \
NATS_URL=${CAATSM_NATS_URL:-nats://localhost:4222} \
SUBJECT=${CAATSM_NATS_SUBJECT:-telegram.serial} \
COUNT=${COUNT:-10} \
CATEGORY=${CATEGORY:-mixed} \
STATUS=${STATUS:-random} \
bash -c '
set -euo pipefail
cmd=(go run ./cmd/seed-telegrams)
if [ -n "$NATS_URL" ]; then
cmd+=("--nats-url" "$NATS_URL")
fi
cmd+=(
--subject "$SUBJECT"
--count "$COUNT"
--category "$CATEGORY"
--status "$STATUS"
)
exec "${cmd[@]}"
'
help: help:
desc: Show this help message desc: Show this help message
cmds: cmds:
+7
View File
@@ -58,6 +58,10 @@ func setupApp() *cli.App {
Name: "stream", Name: "stream",
Usage: "NATS JetStream stream name", Usage: "NATS JetStream stream name",
}, },
&cli.StringFlag{
Name: "nats-mode",
Usage: "NATS mode: jetstream or core",
},
&cli.StringFlag{ &cli.StringFlag{
Name: "consumer", Name: "consumer",
Usage: "NATS JetStream durable consumer", Usage: "NATS JetStream durable consumer",
@@ -193,6 +197,9 @@ func applyCLIOverrides(cfg *config.Config, c *cli.Context) {
if stream := c.String("stream"); stream != "" { if stream := c.String("stream"); stream != "" {
cfg.NATS.Stream = stream cfg.NATS.Stream = stream
} }
if mode := c.String("nats-mode"); mode != "" {
cfg.NATS.Mode = strings.ToLower(mode)
}
if consumer := c.String("consumer"); consumer != "" { if consumer := c.String("consumer"); consumer != "" {
cfg.NATS.Consumer = consumer cfg.NATS.Consumer = consumer
} }
+242
View File
@@ -0,0 +1,242 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"log"
"math/rand"
"strings"
"time"
"github.com/google/uuid"
"github.com/nats-io/nats.go"
)
var (
priorityIndicators = []string{"FF", "GG", "QU"}
primaryAddresses = []string{"ZBTJZPZX", "KSFOZPZX", "KLAXZPZX", "EDDFZPZX"}
originators = []string{"ZBTJYOYX", "KSFOYOYX", "SELOZKE"}
originatorLines = []string{"141604 ZBACZQZX", "150551 ZBTJUOBK", "210930 ZGGGZQZX"}
airports = []string{"ZBTJ", "ZGGG", "KSFO", "KLAX", "EDDF", "RJTT", "EGLL", "ZSPD"}
statusValues = []string{"parsed", "header_error", "body_error", "publish_error", "repository_error"}
bodyCategories = []string{"ARR", "DEP", "CNL", "DLA", "FPL"}
)
func main() {
natsURL := flag.String("nats-url", "nats://127.0.0.1:4222", "NATS server URL (empty skips publish)")
subject := flag.String("subject", "telegram.raw", "Subject to publish telegrams to")
noNATS := flag.Bool("no-nats", false, "Skip NATS publish even if --nats-url is provided")
count := flag.Int("count", 10, "Number of telegrams to publish")
category := flag.String("category", "mixed", "ARR|DEP|CNL|DLA|FPL|mixed")
status := flag.String("status", "body_error", "parsed|header_error|body_error|publish_error|repository_error|random")
errorReason := flag.String("error-reason", "synthetic test payload", "Metadata header describing why message is in raw state")
dryRun := flag.Bool("dry-run", false, "Print telegrams instead of publishing to NATS")
useJetStream := flag.Bool("jetstream", false, "Publish via JetStream")
jsStream := flag.String("stream", "", "JetStream stream (optional when --jetstream)")
jsSubject := flag.String("js-subject", "", "Override subject for JetStream publish (defaults to --subject)")
headerFormat := flag.String("header-format", "json", "Metadata header encoding: json|none")
flag.Parse()
rand.Seed(time.Now().UnixNano())
var nc *nats.Conn
var js nats.JetStreamContext
var err error
if !*dryRun && *natsURL != "" && !*noNATS {
nc, err = nats.Connect(*natsURL)
if err != nil {
log.Fatalf("connect nats: %v", err)
}
defer nc.Drain()
if *useJetStream {
opts := []nats.JSOpt{}
if *jsStream != "" {
opts = append(opts, nats.PublishAsyncMaxPending(256))
}
js, err = nc.JetStream()
if err != nil {
log.Fatalf("init jetstream: %v", err)
}
_ = opts
}
}
categories := bodyCategories
if strings.ToLower(*category) != "mixed" {
categories = []string{strings.ToUpper(*category)}
}
statuses := statusValues
if strings.ToLower(*status) != "random" {
statuses = []string{strings.ToLower(*status)}
}
for i := 0; i < *count; i++ {
cat := categories[rand.Intn(len(categories))]
payload := buildTelegram(cat)
payload.Status = statuses[rand.Intn(len(statuses))]
payload.ErrorReason = *errorReason
payload.Metadata = map[string]string{
"message_id": payload.MessageID,
"category": payload.Category,
"comments": fmt.Sprintf("seeded iteration=%d", i),
"status": payload.Status,
}
if *dryRun {
blob, _ := json.MarshalIndent(payload, "", " ")
fmt.Println(string(blob))
fmt.Println("---")
continue
}
if nc != nil && !*noNATS {
data := []byte(payload.Content)
msg := &nats.Msg{Subject: *subject, Data: data, Header: nats.Header{}}
msg.Header.Set("Nats-Msg-Id", payload.UUID)
if strings.ToLower(*headerFormat) == "json" {
headerJSON, _ := json.Marshal(payload.Metadata)
msg.Header.Set("x-telegram-meta", string(headerJSON))
}
msg.Header.Set("x-telegram-uuid", payload.UUID)
msg.Header.Set("x-telegram-status", payload.Status)
msg.Header.Set("x-telegram-error", payload.ErrorReason)
if js != nil {
pubSubject := *jsSubject
if pubSubject == "" {
pubSubject = *subject
}
msg.Subject = pubSubject
if _, err := js.PublishMsg(msg); err != nil {
log.Fatalf("jetstream publish: %v", err)
}
} else {
if err := nc.PublishMsg(msg); err != nil {
log.Fatalf("nats publish: %v", err)
}
}
}
}
if !*dryRun {
if nc != nil && !*noNATS {
log.Printf("Published %d telegram(s) to %s", *count, *subject)
}
}
}
type telegram struct {
UUID string `json:"uuid"`
MessageID string `json:"message_id"`
Category string `json:"category"`
Status string `json:"status"`
ErrorReason string `json:"error_reason"`
Content string `json:"content"`
ReceivedAt time.Time `json:"received_at"`
Metadata map[string]string `json:"metadata"`
}
func buildTelegram(category string) *telegram {
now := time.Now().UTC()
messageID := fmt.Sprintf("%s%04d", category, rand.Intn(9000)+1000)
headerTime := now.Format("020304")
priority := priorityIndicators[rand.Intn(len(priorityIndicators))]
primary := primaryAddresses[rand.Intn(len(primaryAddresses))]
originLine := originatorLines[rand.Intn(len(originatorLines))]
originator := originators[rand.Intn(len(originators))]
body := buildBody(category)
content := strings.Join([]string{
fmt.Sprintf("ZCZC %s %s", messageID, headerTime),
fmt.Sprintf("%s %s", priority, primary),
originLine,
originator,
body,
"NNNN",
}, "\n")
return &telegram{
UUID: uuid.NewString(),
MessageID: messageID,
Category: category,
Content: content,
ReceivedAt: now,
}
}
func buildBody(category string) string {
flight := fmt.Sprintf("%s%04d", []string{"CCA", "SWA", "DLH", "AAL", "JAE"}[rand.Intn(5)], rand.Intn(9000)+1000)
dep := airports[rand.Intn(len(airports))]
arr := airports[rand.Intn(len(airports))]
depTime := time.Now().UTC().Add(time.Duration(rand.Intn(240)) * time.Minute).Format("1504")
arrTime := time.Now().UTC().Add(time.Duration(rand.Intn(360)) * time.Minute).Format("1504")
switch category {
case "ARR":
if rand.Intn(2) == 0 {
return fmt.Sprintf("(ARR-%s-%s-%s%s)", flight, dep, arr, arrTime)
}
return fmt.Sprintf("(ARR-%s/%s-%s-%s%s)", flight, randomSSR(), dep, arr, arrTime)
case "DEP":
return fmt.Sprintf("(DEP-%s/%s-%s%s-%s)", flight, randomSSR(), dep, depTime, arr)
case "CNL":
return fmt.Sprintf("(CNL-%s-%s-%s)", flight, dep, arr)
case "DLA":
return fmt.Sprintf("(DLA-%s-%s%s-%s)", flight, dep, depTime, arr)
default: // FPL
return fmt.Sprintf(`(FPL-%s-IS
-%s/H
-%s
-%s%s
-K%04dS%04d %s
-%s%s %s
-%s)`,
flight,
randomAircraft(),
randomSSR(),
dep,
depTime,
rand.Intn(9000)+500,
rand.Intn(8000)+400,
randomRoute(),
arr,
arrTime,
randomAirportPair(),
randomOtherInfo(),
)
}
}
func randomSSR() string {
return []string{"A0132", "A5633", "SXIRPZJWY/LB101", "SHID/C"}[rand.Intn(4)]
}
func randomAircraft() string {
return []string{"A332", "B788", "MA60", "A359"}[rand.Intn(4)]
}
func randomRoute() string {
routes := []string{
"PIAKS G330 PIMOL A539 BTO W82 DOGAR",
"CG J1 FZ",
"EBAYY Q11 BSR J65 BCE Q135 KICNE",
}
return routes[rand.Intn(len(routes))]
}
func randomAirportPair() string {
return fmt.Sprintf("%s %s", airports[rand.Intn(len(airports))], airports[rand.Intn(len(airports))])
}
func randomOtherInfo() string {
return []string{
"PBN/A1B2B3B4B5D1L1 NAV/ABAS REG/B6513 EET/ZBPE0112 SEL/KMAL PER/C RIF/FRT N640 ZBYN RMK/TCAS EQUIPPED",
"REG/B3710 SEL/ RMK/TCAS",
"NAV/RNAV1 RNAV5 RNP4 RMK/AGCS EQUIPPED",
}[rand.Intn(3)]
}
+1
View File
@@ -1,5 +1,6 @@
[nats] [nats]
url = "nats://localhost:4222" url = "nats://localhost:4222"
mode = "core"
client = "serial-client" client = "serial-client"
cluster = "tele-cluster" cluster = "tele-cluster"
stream = "TELEGRAM" stream = "TELEGRAM"
+16
View File
@@ -0,0 +1,16 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: true
- name: Jaeger
type: jaeger
access: proxy
url: http://jaeger:16686
editable: true
+5 -1
View File
@@ -9,12 +9,16 @@ receivers:
exporters: exporters:
logging: logging:
loglevel: info loglevel: info
otlp/jaeger:
endpoint: jaeger:14250
tls:
insecure: true
service: service:
pipelines: pipelines:
traces: traces:
receivers: [otlp] receivers: [otlp]
exporters: [logging] exporters: [logging, otlp/jaeger]
metrics: metrics:
receivers: [otlp] receivers: [otlp]
exporters: [logging] exporters: [logging]
+16
View File
@@ -0,0 +1,16 @@
global:
scrape_interval: 10s
evaluation_interval: 10s
scrape_configs:
- job_name: "otel-collector"
static_configs:
- targets:
- "otel-collector:8888"
- job_name: "nats"
metrics_path: /varz
scheme: http
static_configs:
- targets:
- "nats:8222"
+47
View File
@@ -47,11 +47,58 @@ services:
ports: ports:
- "4317:4317" - "4317:4317"
- "4318:4318" - "4318:4318"
- "8888:8888"
- "8889:8889"
- "13133:13133"
- "55679:55679"
networks:
- devnet
jaeger:
image: jaegertracing/all-in-one:1.60
ports:
- "16686:16686"
- "14250:14250"
environment:
- COLLECTOR_OTLP_ENABLED=true
- LOG_LEVEL=debug
networks:
- devnet
prometheus:
image: prom/prometheus:v2.53.0
command:
- "--config.file=/etc/prometheus/prometheus.yml"
- "--storage.tsdb.path=/prometheus"
- "--web.enable-lifecycle"
- "--storage.tsdb.retention.time=1h"
ports:
- "9090:9090"
volumes:
- ./configs/prometheus.dev.yml:/etc/prometheus/prometheus.yml:ro
networks:
- devnet
grafana:
image: grafana/grafana:11.2.2
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: admin
GF_USERS_ALLOW_SIGN_UP: "false"
ports:
- "3000:3000"
volumes:
- grafana-data:/var/lib/grafana
- ./configs/grafana-datasources.dev.yml:/etc/grafana/provisioning/datasources/datasources.yml:ro
depends_on:
- prometheus
- jaeger
networks: networks:
- devnet - devnet
volumes: volumes:
postgres-data: postgres-data:
grafana-data:
networks: networks:
devnet: devnet:
+133
View File
@@ -0,0 +1,133 @@
# Development Guide
This document describes how to run the full development stack—database, NATS, and observability tooling—using `docker-compose.dev.yml`. All commands assume you are at the repository root.
## Core Services (TimescaleDB + NATS)
Spin up PostgreSQL/TimescaleDB and NATS JetStream in the background:
```bash
docker compose -f docker-compose.dev.yml up -d postgres nats nats-box
```
> Development mode defaults to `nats.mode = "core"`, so the processor consumes directly from the configured subject (`subscription.topic`). **However, the publisher always targets JetStream for deduplicated fan-out, so the provided Taskfile (and most examples below) override the mode to `jetstream`.** If you truly need core mode, set `CAATSM_NATS_MODE=core` manually and ensure any publishers use core subjects.
- `postgres` seeds the `aviation` schema using `internal/repository/telegrams.ddl` and exposes port `5432`.
- `nats` enables JetStream with client port `4222` and monitoring/UI on `8222`.
- `nats-box` provides a toolbox container (`docker compose exec nats-box sh`) for publishing test messages or inspecting JetStream.
Prefer to run the Go application on your host for quick iteration while keeping infra in Docker:
```bash
GO_ENV=dev \
CAATSM_POSTGRES_URL=postgres://caatsm:caatsm@localhost:5432/aviation?sslmode=disable \
go run ./cmd/main listen
```
Stop and clean the stack when finished:
```bash
docker compose -f docker-compose.dev.yml down -v
```
### Using Taskfile shortcuts
The `Taskfile.yml` includes helper targets that wrap the commands above:
- `task up` starts PostgreSQL, NATS, and the observability stack (OpenTelemetry Collector, Jaeger, Prometheus, Grafana) using Docker Compose.
- `task dev-run` ensures `task up` has run, exports the necessary `CAATSM_*` environment variables (including `CAATSM_NATS_MODE=jetstream`), and executes `go run ./cmd/main listen` with telemetry enabled.
- `task down` stops the entire stack and removes containers/volumes.
Use these tasks if you prefer a one-command workflow instead of invoking `docker compose` and environment exports manually.
## Publishing Sample Telegrams
Use the helper CLI in `cmd/seed-telegrams` to push realistic payloads onto NATS (mirrors the fixtures in `internal/parsers/aviation_parser_test.go`):
```bash
# Insert rows into aviation.telegrams_raw and publish to NATS simultaneously
GO_ENV=dev go run ./cmd/seed-telegrams \
--postgres-url postgres://caatsm:caatsm@localhost:5432/aviation?sslmode=disable \
--nats-url nats://127.0.0.1:4222 \
--subject telegram.serial \
--count 20 \
--category mixed \
--status random
```
- `--postgres-url` controls database insertion (omit to skip DB writes); metadata lands in `aviation.telegrams_raw.metadata`.
- `--dry-run` prints telegrams without touching NATS/Postgres.
- `--category` chooses ARR/DEP/CNL/DLA/FPL or `mixed`.
- `--status` controls stored/published status (`parsed|header_error|body_error|publish_error|repository_error|random`).
- `--no-nats` disables publishing; `--jetstream`, `--stream`, `--js-subject` toggle JetStream publishing.
- Inspect deliveries with `docker compose exec nats-box nats sub 'telegram.>'`.
- When running in core mode (default), the seeder publishes via standard `nc.Publish` and sets `Nats-Msg-Id` headers so the processor can derive message IDs.
The main processor keeps consuming `subscription.topic` (defaults to `telegram.>`). Use the seeder to simulate parser failures, publish errors, or replay raw telegrams directly from the database.
## Tracing with Jaeger
1. **Start the observability stack**
```bash
docker compose -f docker-compose.dev.yml up -d otel-collector jaeger prometheus grafana
```
- Jaeger UI runs at <http://localhost:16686>.
- The OTLP HTTP collector endpoint is available at `http://localhost:4318`.
2. **Run the processor with telemetry enabled**
```bash
CAATSM_TELEMETRY_ENABLED=true \
CAATSM_TELEMETRY_ENDPOINT=localhost:4318 \
CAATSM_TELEMETRY_INSECURE=true \
GO_ENV=dev \
CAATSM_NATS_MODE=jetstream \
CAATSM_POSTGRES_URL=postgres://caatsm:caatsm@localhost:5432/aviation?sslmode=disable \
go run ./cmd/main listen
```
- The service name reported to Jaeger is `caatsm`.
3. **Generate traffic**
```bash
task seed COUNT=5
```
or publish manually with `go run ./cmd/seed-telegrams`.
4. **Inspect traces**
- Open <http://localhost:16686>, choose the `caatsm` service, and click “Find Traces”.
- Filter by operation name (e.g., `Consumer.processMessage`) or by time range to drill into individual telegram processing flows.
## Observability Dashboard Stack
The dev compose file also includes OpenTelemetry Collector, Jaeger, Prometheus, and Grafana so you can inspect traces and metrics emitted by the processor.
```bash
docker compose -f docker-compose.dev.yml up -d \
postgres nats otel-collector jaeger prometheus grafana
```
Services:
- `otel-collector`
- Loads `configs/otel-collector.dev.yaml`
- Ports: OTLP gRPC `4317`, OTLP HTTP `4318`, Prometheus scrape `8888`, Prometheus exporter `8889`, health `13133`, zPages `55679`
- Exports traces to Jaeger via the built-in OTLP gRPC exporter (secured with `tls.insecure: true`)
- `jaeger`
- Receives OTLP traffic forwarded from the collector on `14250` gRPC and serves the UI at <http://localhost:16686>
- `prometheus`
- Uses `configs/prometheus.dev.yml` to scrape the collector and NATS monitoring endpoint; UI available at <http://localhost:9090>
- `grafana`
- Persists data in `grafana-data`, provisions datasources via `configs/grafana-datasources.dev.yml`, and listens on <http://localhost:3000> (login `admin` / `admin`)
### Customizing Collections & Dashboards
- Adjust `configs/prometheus.dev.yml` to add/remove scrape jobs—for example, include your applications `/metrics` endpoint.
- Add more Grafana provisioning files (dashboards, alert rules) under `configs/` and mount them in `docker-compose.dev.yml`.
- To ingest telemetry from local services, configure their OTLP exporters to target `http://localhost:4318` (HTTP) or `grpc://localhost:4317`.
## Troubleshooting
- **PostgreSQL init errors**: ensure `internal/repository/telegrams.ddl` is valid SQL and the `postgres-data` volume is removed (`docker volume rm go-caatsm_postgres-data`) before restarting.
- **NATS connection failures**: confirm ports `4222/8222` are free and JetStream is enabled; use `docker compose logs nats`.
- **Prometheus scrape failures**: verify endpoints listed in `configs/prometheus.dev.yml` match the service names defined in Docker Compose.
- **Grafana provisioning issues**: check container logs (`docker compose logs grafana`) to ensure the datasources file was read; correct file permissions or YAML formatting if provisioning is skipped.
+18 -7
View File
@@ -28,6 +28,7 @@ type Config struct {
// NATSConfig holds NATS/JetStream configuration // NATSConfig holds NATS/JetStream configuration
type NATSConfig struct { type NATSConfig struct {
URL string `koanf:"url"` URL string `koanf:"url"`
Mode string `koanf:"mode"`
Stream string `koanf:"stream"` Stream string `koanf:"stream"`
Consumer string `koanf:"consumer"` Consumer string `koanf:"consumer"`
StreamLimits StreamLimitsConfig `koanf:"stream_limits"` StreamLimits StreamLimitsConfig `koanf:"stream_limits"`
@@ -49,14 +50,14 @@ type StreamLimitsConfig struct {
// ConsumerRulesConfig captures consumer-level options. // ConsumerRulesConfig captures consumer-level options.
type ConsumerRulesConfig struct { type ConsumerRulesConfig struct {
MaxDeliver int `koanf:"max_deliver"` MaxDeliver int `koanf:"max_deliver"`
AckWait time.Duration `koanf:"ack_wait"` AckWait time.Duration `koanf:"ack_wait"`
MaxAckPending int `koanf:"max_ack_pending"` MaxAckPending int `koanf:"max_ack_pending"`
DeliverPolicy string `koanf:"deliver_policy"` DeliverPolicy string `koanf:"deliver_policy"`
ReplayPolicy string `koanf:"replay_policy"` ReplayPolicy string `koanf:"replay_policy"`
Backoff []time.Duration `koanf:"backoff"` Backoff []time.Duration `koanf:"backoff"`
StartSequence uint64 `koanf:"start_sequence"` StartSequence uint64 `koanf:"start_sequence"`
StartTime string `koanf:"start_time"` StartTime string `koanf:"start_time"`
} }
// PostgresConfig holds PostgreSQL configuration // PostgresConfig holds PostgreSQL configuration
@@ -160,6 +161,11 @@ func LoadConfig() (*Config, error) {
if cfg.Log.Format == "" { if cfg.Log.Format == "" {
cfg.Log.Format = "json" cfg.Log.Format = "json"
} }
if cfg.NATS.Mode == "" {
cfg.NATS.Mode = "jetstream"
} else {
cfg.NATS.Mode = strings.ToLower(cfg.NATS.Mode)
}
if cfg.NATS.Stream == "" { if cfg.NATS.Stream == "" {
cfg.NATS.Stream = "TELEGRAM" cfg.NATS.Stream = "TELEGRAM"
} }
@@ -219,6 +225,11 @@ func (c *Config) Validate() error {
if c.NATS.URL == "" { if c.NATS.URL == "" {
return fmt.Errorf("nats.url is required") return fmt.Errorf("nats.url is required")
} }
switch strings.ToLower(c.NATS.Mode) {
case "", "jetstream", "core":
default:
return fmt.Errorf("nats.mode must be 'jetstream' or 'core'")
}
if c.NATS.Stream == "" { if c.NATS.Stream == "" {
return fmt.Errorf("nats.stream is required") return fmt.Errorf("nats.stream is required")
} }
+70 -3
View File
@@ -9,6 +9,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/google/uuid"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"go.opentelemetry.io/otel" "go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/attribute"
@@ -26,6 +27,7 @@ type Consumer struct {
logger *zap.Logger logger *zap.Logger
subject string subject string
consumerName string consumerName string
mode string
meter metric.Meter meter metric.Meter
ackPending metric.Int64Histogram ackPending metric.Int64Histogram
redelivered metric.Int64Histogram redelivered metric.Int64Histogram
@@ -48,6 +50,11 @@ func ProvideConsumer(
consumerName = "telegram-consumer" consumerName = "telegram-consumer"
} }
mode := strings.ToLower(cfg.NATS.Mode)
if mode == "" {
mode = "jetstream"
}
consumer := &Consumer{ consumer := &Consumer{
conn: conn, conn: conn,
js: js, js: js,
@@ -56,12 +63,20 @@ func ProvideConsumer(
logger: logger, logger: logger,
subject: subject, subject: subject,
consumerName: consumerName, consumerName: consumerName,
mode: mode,
} }
consumer.initMetrics() consumer.initMetrics()
// Create consumer if it doesn't exist if consumer.mode == "jetstream" {
if err := consumer.ensureConsumer(); err != nil { // Create consumer if it doesn't exist
return nil, fmt.Errorf("failed to ensure consumer: %w", err) if err := consumer.ensureConsumer(); err != nil {
return nil, fmt.Errorf("failed to ensure consumer: %w", err)
}
} else {
logger.Info("Running consumer in core NATS mode",
zap.String("subject", subject),
zap.String("queue_group", cfg.Subscription.QueueGroup),
)
} }
return consumer, nil return consumer, nil
@@ -130,6 +145,14 @@ func (c *Consumer) ensureConsumer() error {
// Start starts consuming messages // Start starts consuming messages
func (c *Consumer) Start(ctx context.Context) error { func (c *Consumer) Start(ctx context.Context) error {
if c.mode == "core" {
return c.startCore(ctx)
}
return c.startJetStream(ctx)
}
func (c *Consumer) startJetStream(ctx context.Context) error {
streamName := c.cfg.NATS.Stream streamName := c.cfg.NATS.Stream
if streamName == "" { if streamName == "" {
streamName = "TELEGRAM" streamName = "TELEGRAM"
@@ -224,6 +247,46 @@ func (c *Consumer) Start(ctx context.Context) error {
} }
} }
func (c *Consumer) startCore(ctx context.Context) error {
queueGroup := c.cfg.Subscription.QueueGroup
if queueGroup == "" {
queueGroup = c.consumerName
}
handler := func(msg *nats.Msg) {
if err := c.processMessage(ctx, msg); err != nil {
isPermanent := app.IsPermanent(err)
c.logger.Error("Failed to process message (core mode)",
zap.String("subject", msg.Subject),
zap.Error(err),
zap.Bool("permanent", isPermanent),
)
}
}
sub, err := c.conn.QueueSubscribe(c.subject, queueGroup, handler)
if err != nil {
return fmt.Errorf("failed to subscribe to %s: %w", c.subject, err)
}
if err := c.conn.Flush(); err != nil {
return fmt.Errorf("failed to flush NATS connection: %w", err)
}
c.logger.Info("Started core NATS subscription",
zap.String("subject", c.subject),
zap.String("queue_group", queueGroup),
)
<-ctx.Done()
c.logger.Info("Stopping core NATS consumer", zap.Error(ctx.Err()))
if err := sub.Drain(); err != nil && !errors.Is(err, nats.ErrConnectionClosed) {
return fmt.Errorf("failed to drain core subscription: %w", err)
}
return ctx.Err()
}
func (c *Consumer) emitConsumerStats(ctx context.Context, streamName string) { func (c *Consumer) emitConsumerStats(ctx context.Context, streamName string) {
interval := c.cfg.App.MonitorInterval interval := c.cfg.App.MonitorInterval
if interval <= 0 { if interval <= 0 {
@@ -393,6 +456,10 @@ func (c *Consumer) resolveMsgID(msg *nats.Msg) (string, string, error) {
return id, "header", nil return id, "header", nil
} }
if c.mode == "core" {
return uuid.NewString(), "generated", nil
}
meta, err := msg.Metadata() meta, err := msg.Metadata()
if err != nil { if err != nil {
return "", "", fmt.Errorf("fetch metadata: %w", err) return "", "", fmt.Errorf("fetch metadata: %w", err)
+4 -2
View File
@@ -20,7 +20,8 @@ CREATE TABLE aviation.telegrams (
parsed_at TIMESTAMPTZ, parsed_at TIMESTAMPTZ,
dispatched_at TIMESTAMPTZ, dispatched_at TIMESTAMPTZ,
need_dispatch BOOLEAN, need_dispatch BOOLEAN,
PRIMARY KEY (uuid, received_at) PRIMARY KEY (uuid, received_at),
CONSTRAINT telegrams_uuid_unique UNIQUE (uuid)
); );
SELECT create_hypertable('aviation.telegrams', 'received_at', if_not_exists => TRUE); SELECT create_hypertable('aviation.telegrams', 'received_at', if_not_exists => TRUE);
@@ -40,5 +41,6 @@ CREATE TABLE IF NOT EXISTS aviation.telegrams_raw (
content TEXT NOT NULL, content TEXT NOT NULL,
received_at TIMESTAMPTZ NOT NULL, received_at TIMESTAMPTZ NOT NULL,
metadata JSONB, metadata JSONB,
PRIMARY KEY (uuid, received_at) PRIMARY KEY (uuid, received_at),
CONSTRAINT telegrams_raw_uuid_unique UNIQUE (uuid)
); );