main.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  1. // Command deliverd-fcm consumes deliveries.fcm.<company_id> subjects
  2. // and posts each alert to the FCM HTTP v1 endpoint. M3 splits
  3. // this out of the original `cmd/deliverd` (which is now two
  4. // per-channel binaries: deliverd-fcm and deliverd-telegram).
  5. // That gives us the option to scale, restart, and deploy each
  6. // channel independently — your Q2 answer.
  7. //
  8. // M0: per-channel worker binary that connects to NATS, /health, /metrics.
  9. // M1: deliverd-fcm — consumes deliveries.fcm.*, posts to fakefcmd,
  10. // records a row in Postgres per attempt.
  11. // M3: rename only. No behavior change. The HTTP target URL is
  12. // BA_FAKECMD_URL (dev) or BA_FCM_BASE_URL (prod); M11+ will
  13. // add real FCM auth.
  14. // M8: in-process retry with exp backoff (10 attempts, default)
  15. // and a DLQ insert on terminal failure. See SPEC §9.
  16. package main
  17. import (
  18. "bytes"
  19. "context"
  20. "encoding/json"
  21. "fmt"
  22. "io"
  23. "log/slog"
  24. "net/http"
  25. "os"
  26. "os/signal"
  27. "strings"
  28. "syscall"
  29. "time"
  30. "git3.techno-world.net/lrosales/broad-announce/internal/broker"
  31. "git3.techno-world.net/lrosales/broad-announce/internal/config"
  32. "git3.techno-world.net/lrosales/broad-announce/internal/dlq"
  33. "git3.techno-world.net/lrosales/broad-announce/internal/httpserver"
  34. "git3.techno-world.net/lrosales/broad-announce/internal/observability"
  35. "git3.techno-world.net/lrosales/broad-announce/internal/postgres"
  36. "git3.techno-world.net/lrosales/broad-announce/internal/retry"
  37. "github.com/nats-io/nats.go/jetstream"
  38. )
  39. // deliverdMetrics is the package-level metrics instance.
  40. // Wired in main() from observability.NewDeliverdMetrics.
  41. var deliverdMetrics *observability.DeliverdMetrics
  42. // M2: only the FCM channel. M3+ adds telegram, sms, etc.
  43. const fcmChannel = "fcm"
  44. type deliveryEnvelope struct {
  45. Alert json.RawMessage `json:"alert"`
  46. IndividualID string `json:"individual_id"`
  47. Channel string `json:"channel"`
  48. Endpoint string `json:"endpoint"`
  49. Locale string `json:"locale,omitempty"`
  50. }
  51. func main() {
  52. cfg, err := config.LoadCommon("deliverd")
  53. if err != nil {
  54. os.Stderr.WriteString("config: " + err.Error() + "\n")
  55. os.Exit(1)
  56. }
  57. logger := observability.Init(cfg.Env, cfg.LogLevel, "deliverd")
  58. logger.Info("starting",
  59. "env", cfg.Env,
  60. "addr", cfg.HTTPAddr,
  61. "max_attempts", cfg.DeliverdMaxAttempts,
  62. "retry_base_ms", cfg.DeliverdRetryBaseMs,
  63. "retry_max_ms", cfg.DeliverdRetryMaxMs,
  64. "retry_budget_ms", cfg.DeliverdRetryBudgetMs,
  65. )
  66. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
  67. defer stop()
  68. br, err := broker.Connect(ctx, cfg.NATSURL)
  69. if err != nil {
  70. logger.Error("nats connect", "err", err)
  71. os.Exit(1)
  72. }
  73. defer br.Close()
  74. pool, err := postgres.Connect(ctx, cfg.PostgresDSN)
  75. if err != nil {
  76. logger.Error("postgres connect", "err", err)
  77. os.Exit(1)
  78. }
  79. defer pool.Close()
  80. // M1 only: fakefcmd URL. M3+ uses the real FCM HTTP v1 endpoint.
  81. fakefcmdURL := os.Getenv("BA_FAKECMD_URL")
  82. if fakefcmdURL == "" {
  83. fakefcmdURL = "http://fakefcmd:8820"
  84. }
  85. logger.Info("fcm target", "url", fakefcmdURL)
  86. httpClient := &http.Client{Timeout: 10 * time.Second}
  87. // M8: build the retry config from env. Defaults match
  88. // internal/retry.Default() so the .env.example can stay
  89. // sparse; ops can override per-deploy.
  90. retryCfg := retry.Config{
  91. MaxAttempts: cfg.DeliverdMaxAttempts,
  92. BaseDelay: time.Duration(cfg.DeliverdRetryBaseMs) * time.Millisecond,
  93. MaxDelay: time.Duration(cfg.DeliverdRetryMaxMs) * time.Millisecond,
  94. Budget: time.Duration(cfg.DeliverdRetryBudgetMs) * time.Millisecond,
  95. }
  96. js := br.JS()
  97. stream, err := js.Stream(ctx, "DELIVERIES")
  98. if err != nil {
  99. logger.Error("nats stream DELIVERIES", "err", err)
  100. os.Exit(1)
  101. }
  102. consumer, err := stream.CreateOrUpdateConsumer(ctx, jetstream.ConsumerConfig{
  103. Name: "deliverd-fcm",
  104. Durable: "deliverd-fcm",
  105. FilterSubjects: []string{"deliveries.fcm.>"},
  106. AckPolicy: jetstream.AckExplicitPolicy,
  107. })
  108. if err != nil {
  109. logger.Error("nats consumer", "err", err)
  110. os.Exit(1)
  111. }
  112. runCtx, runCancel := context.WithCancel(ctx)
  113. defer runCancel()
  114. // M9: deliverd metrics. One registry for both fcm and telegram
  115. // deliverds so they get separate service labels.
  116. reg, _ := observability.NewRegistry("deliverd")
  117. deliverdMetrics = observability.NewDeliverdMetrics(reg, "deliverd")
  118. go consume(runCtx, logger, consumer, pool, httpClient, fakefcmdURL, retryCfg)
  119. srv := httpserver.New(httpserver.Config{
  120. Addr: cfg.HTTPAddr,
  121. ServiceName: "deliverd",
  122. ShutdownGrace: cfg.ShutdownGrace,
  123. }, logger, observability.MetricsHandler(reg))
  124. // M13a W5: admin routes (JWT-gated). When BA_AUTHD_JWT_SECRET
  125. // is unset, wireAdminRoutes is a no-op so the LAN deploy
  126. // path keeps working unchanged.
  127. wireAdminRoutes(srv.Mux(), pool, logger)
  128. errCh := make(chan error, 1)
  129. go func() { errCh <- srv.Start() }()
  130. select {
  131. case <-ctx.Done():
  132. logger.Info("shutdown signal received")
  133. case err := <-errCh:
  134. if err != nil {
  135. logger.Error("http server", "err", err)
  136. os.Exit(1)
  137. }
  138. }
  139. runCancel()
  140. time.Sleep(500 * time.Millisecond)
  141. if err := srv.Shutdown(ctx); err != nil {
  142. logger.Warn("graceful shutdown", "err", err)
  143. }
  144. logger.Info("bye")
  145. }
  146. func consume(ctx context.Context, logger *slog.Logger, c jetstream.Consumer, pool *postgres.Pool, httpClient *http.Client, fakefcmdURL string, retryCfg retry.Config) {
  147. for {
  148. if ctx.Err() != nil {
  149. return
  150. }
  151. batch, err := c.Fetch(16, jetstream.FetchMaxWait(2*time.Second))
  152. if err != nil {
  153. if ctx.Err() != nil {
  154. return
  155. }
  156. logger.Warn("nats fetch", "err", err)
  157. time.Sleep(500 * time.Millisecond)
  158. continue
  159. }
  160. for m := range batch.Messages() {
  161. handleOne(ctx, logger, m, pool, httpClient, fakefcmdURL, retryCfg)
  162. if batch.Error() != nil {
  163. logger.Warn("batch error", "err", batch.Error())
  164. break
  165. }
  166. }
  167. }
  168. }
  169. // handleOne processes a single delivery. M8 split the
  170. // work into three steps:
  171. // 1. Parse the envelope; if it's malformed, Ack and
  172. // bail (no DLQ for un-parseable data — there's no
  173. // payload to replay anyway).
  174. // 2. Run the retry loop. Each attempt POSTs to the
  175. // target and writes a `deliveries` audit row. On
  176. // the final failure, write a `deliveries_dlq` row.
  177. // 3. Ack the NATS message; the work is done one way
  178. // or another (sent, or durably parked in the DLQ).
  179. func handleOne(ctx context.Context, logger *slog.Logger, m jetstream.Msg, pool *postgres.Pool, httpClient *http.Client, fakefcmdURL string, retryCfg retry.Config) {
  180. var env deliveryEnvelope
  181. if err := json.Unmarshal(m.Data(), &env); err != nil {
  182. logger.Warn("malformed delivery envelope", "err", err, "subject", m.Subject())
  183. _ = m.Ack() // poison message; we don't have a DLQ for malformed yet
  184. return
  185. }
  186. // Parse out the alert_id and company_id from the inner alert JSON.
  187. var alertHeader struct {
  188. ID string `json:"id"`
  189. CompanyID string `json:"company_id"`
  190. Title string `json:"title"`
  191. Body string `json:"body"`
  192. Data map[string]string `json:"data"`
  193. Category string `json:"category"`
  194. Severity string `json:"severity"`
  195. DedupeCount uint32 `json:"dedupe_count"`
  196. }
  197. _ = json.Unmarshal(env.Alert, &alertHeader)
  198. companyID := alertHeader.CompanyID
  199. if companyID == "" {
  200. // subject is "deliveries.fcm.<company_id>"
  201. parts := strings.SplitN(m.Subject(), ".", 3)
  202. if len(parts) == 3 {
  203. companyID = parts[2]
  204. }
  205. }
  206. if companyID == "" || env.Endpoint == "" || alertHeader.ID == "" {
  207. logger.Warn("delivery envelope missing fields", "subject", m.Subject(), "company", companyID, "endpoint_present", env.Endpoint != "", "alert_id", alertHeader.ID)
  208. _ = m.Ack()
  209. return
  210. }
  211. // Build the FCM HTTP v1 message body once. Same shape
  212. // across attempts. M6 dedupe_count flow-through is
  213. // unchanged.
  214. notificationTitle := alertHeader.Title
  215. if alertHeader.DedupeCount > 1 {
  216. notificationTitle = fmt.Sprintf("%s (×%d)", alertHeader.Title, alertHeader.DedupeCount)
  217. }
  218. fcmBody, _ := json.Marshal(map[string]any{
  219. "message": map[string]any{
  220. "token": env.Endpoint,
  221. "notification": map[string]any{
  222. "title": notificationTitle,
  223. "body": alertHeader.Body,
  224. },
  225. "data": mergeData(alertHeader.Data, map[string]string{
  226. "company_id": companyID,
  227. "alert_id": alertHeader.ID,
  228. "severity": alertHeader.Severity,
  229. "category": alertHeader.Category,
  230. "dedupe_count": fmt.Sprintf("%d", alertHeader.DedupeCount),
  231. "individual_id": env.IndividualID,
  232. "locale": env.Locale,
  233. "deep_link": fmt.Sprintf("broadannounce://alert/%s", alertHeader.ID),
  234. }),
  235. "android": map[string]any{
  236. "priority": androidPriority(alertHeader.Severity),
  237. "notification": map[string]any{
  238. "sound": androidSound(alertHeader.Category, alertHeader.Severity),
  239. "channel_id": "alerts." + channelForSeverity(alertHeader.Severity),
  240. },
  241. },
  242. },
  243. })
  244. url := fakefcmdURL + "/v1/projects/fakefcmd/messages:send"
  245. // M9: track attempt start time for DLQ latency metric.
  246. firstAttemptTime := time.Now()
  247. // M8: retry loop. Each attempt: POST, persist a
  248. // deliveries row with status sent/failed and the
  249. // attempt counter, return the error to the helper.
  250. // On success, the loop exits early and we write one
  251. // final 'sent' row. On exhaustion, we write a DLQ
  252. // row and a final 'dlq' audit row.
  253. //
  254. // The ctx is the per-message context; we want a
  255. // per-attempt timeout, so we use a child ctx.
  256. var lastErr error
  257. var sent bool
  258. res := retry.Run(ctx, retryCfg, func(attemptCtx context.Context, attempt int) error {
  259. // Per-attempt timeout: 10s. The retry.Config
  260. // budget is the outer wall-clock cap.
  261. attemptCtx, cancel := context.WithTimeout(attemptCtx, 10*time.Second)
  262. defer cancel()
  263. req, _ := http.NewRequestWithContext(attemptCtx, "POST", url, bytes.NewReader(fcmBody))
  264. req.Header.Set("Content-Type", "application/json")
  265. resp, err := httpClient.Do(req)
  266. status := "failed"
  267. lastErrStr := ""
  268. if err != nil {
  269. lastErrStr = err.Error()
  270. } else {
  271. respBody, _ := io.ReadAll(resp.Body)
  272. _ = resp.Body.Close()
  273. if resp.StatusCode/100 == 2 {
  274. status = "sent"
  275. sent = true
  276. } else {
  277. lastErrStr = fmt.Sprintf("status %d: %s", resp.StatusCode, truncate(string(respBody), 200))
  278. }
  279. }
  280. // Per-attempt deliveries row. M1 pattern: one row
  281. // per attempt for the audit trail. The M8 status
  282. // enum is unchanged (pending|sent|failed|dlq).
  283. _, dbErr := pool.Exec(attemptCtx, `
  284. INSERT INTO deliveries
  285. (alert_id, company_id, individual_id, channel, target, status, attempts, last_error, payload, sent_at)
  286. VALUES ($1, $2, $3, $4, $5, $6, $7, NULLIF($8, ''), $9,
  287. CASE WHEN $6 = 'sent' THEN now() ELSE NULL END)
  288. `, alertHeader.ID, companyID, env.IndividualID, fcmChannel, env.Endpoint, status, attempt, lastErrStr, json.RawMessage(m.Data()))
  289. if dbErr != nil {
  290. logger.Warn("delivery row insert", "err", dbErr, "attempt", attempt)
  291. }
  292. // M9: record per-channel delivery attempt metric.
  293. if deliverdMetrics != nil {
  294. deliverdMetrics.DeliveryAttempts.WithLabelValues(fcmChannel, status).Inc()
  295. }
  296. if status == "sent" {
  297. logger.Info("delivery sent",
  298. "alert_id", alertHeader.ID,
  299. "company", companyID,
  300. "individual", env.IndividualID,
  301. "channel", fcmChannel,
  302. "attempt", attempt,
  303. )
  304. return nil
  305. }
  306. // M8: surface the last error to the retry helper
  307. // so it logs and (on exhaustion) gets written to
  308. // the DLQ row. We use a PermanentError for 4xx
  309. // (other than 408/429) so the retry helper
  310. // short-circuits — FCM's 4xx means the token is
  311. // bad, retrying won't help.
  312. if resp != nil && resp.StatusCode >= 400 && resp.StatusCode < 500 && resp.StatusCode != 408 && resp.StatusCode != 429 {
  313. logger.Warn("permanent fcm error",
  314. "alert_id", alertHeader.ID,
  315. "status", resp.StatusCode,
  316. "err", lastErrStr,
  317. "attempt", attempt,
  318. )
  319. lastErr = &retry.PermanentError{Err: fmt.Errorf("fcm permanent %s", lastErrStr)}
  320. return lastErr
  321. }
  322. logger.Warn("delivery attempt failed",
  323. "alert_id", alertHeader.ID,
  324. "company", companyID,
  325. "channel", fcmChannel,
  326. "attempt", attempt,
  327. "err", lastErrStr,
  328. )
  329. lastErr = fmt.Errorf("%s", lastErrStr)
  330. return lastErr
  331. })
  332. if sent {
  333. _ = m.Ack()
  334. return
  335. }
  336. // All attempts exhausted (or permanent error). Write
  337. // a DLQ row so an operator can replay. Use the
  338. // original NATS subject so replay doesn't need to
  339. // re-derive it from the payload.
  340. dlqID, dlqErr := dlq.Write(ctx, pool, dlq.Entry{
  341. AlertID: alertHeader.ID,
  342. CompanyID: companyID,
  343. IndividualID: env.IndividualID,
  344. Channel: fcmChannel,
  345. Target: env.Endpoint,
  346. OriginalSubject: m.Subject(),
  347. Attempts: res.Attempts,
  348. LastError: errString(lastErr),
  349. Payload: json.RawMessage(m.Data()),
  350. })
  351. if dlqErr != nil {
  352. // The DLQ write itself failed. Log loud; we
  353. // still Ack the NATS message (the original
  354. // delivery is lost, but the live `deliveries`
  355. // audit row has the failure recorded). An
  356. // operator alert can be wired in M9.
  357. logger.Error("dlq insert FAILED",
  358. "err", dlqErr,
  359. "alert_id", alertHeader.ID,
  360. "company", companyID,
  361. )
  362. } else {
  363. logger.Warn("delivery → DLQ",
  364. "dlq_id", dlqID,
  365. "alert_id", alertHeader.ID,
  366. "company", companyID,
  367. "channel", fcmChannel,
  368. "attempts", res.Attempts,
  369. "err", errString(lastErr),
  370. )
  371. // M9: record DLQ metric and latency.
  372. if deliverdMetrics != nil {
  373. deliverdMetrics.DLQTotal.WithLabelValues(fcmChannel).Inc()
  374. deliverdMetrics.DLQLatency.Observe(time.Since(firstAttemptTime).Seconds())
  375. deliverdMetrics.RetryAttempts.WithLabelValues(fcmChannel).Add(float64(res.Attempts))
  376. }
  377. }
  378. _ = m.Ack()
  379. }
  380. func mergeData(base, add map[string]string) map[string]string {
  381. if base == nil {
  382. base = map[string]string{}
  383. }
  384. for k, v := range add {
  385. if _, ok := base[k]; !ok {
  386. base[k] = v
  387. }
  388. }
  389. return base
  390. }
  391. func androidPriority(sev string) string {
  392. if sev == "critical" || sev == "inminent_colapse" {
  393. return "HIGH"
  394. }
  395. return "NORMAL"
  396. }
  397. // androidSound picks a sound file. SPEC §4 says the Android app
  398. // uses data.category to pick siren_<category>.ogg; the per-severity
  399. // override for inminent_colapse wins.
  400. func androidSound(category, sev string) string {
  401. if sev == "inminent_colapse" {
  402. return "klaxon"
  403. }
  404. if category == "" {
  405. return "default"
  406. }
  407. return "siren_" + category
  408. }
  409. func channelForSeverity(sev string) string {
  410. switch sev {
  411. case "critical":
  412. return "critical"
  413. case "inminent_colapse":
  414. return "imminent"
  415. case "warning":
  416. return "warning"
  417. default:
  418. return "info"
  419. }
  420. }
  421. func errString(err error) string {
  422. if err == nil {
  423. return ""
  424. }
  425. return err.Error()
  426. }
  427. // truncate keeps a status-line response body to N bytes
  428. // for the deliveries.last_error column. We never want a
  429. // multi-MB error body in the audit row.
  430. func truncate(s string, n int) string {
  431. if len(s) <= n {
  432. return s
  433. }
  434. return s[:n] + "…"
  435. }