diff --git a/build.sbt b/build.sbt index 612a321..7328f33 100644 --- a/build.sbt +++ b/build.sbt @@ -76,6 +76,15 @@ lazy val common = project.in(file("common")) ) .enablePlugins(AkkaGrpcPlugin) +// Story 13.9 — dependency-light metrics module (only the Prometheus client). Kept separate from +// common so downstream consumers can depend on notification metrics without the full common stack. +lazy val metrics = project.in(file("metrics")) + .configs(IntegrationTest) + .settings( + Defaults.itSettings, + moduleSettings + ) + lazy val core = project.in(file("core")) .configs(IntegrationTest) .settings( @@ -85,7 +94,8 @@ lazy val core = project.in(file("core")) ) .enablePlugins(BuildInfoPlugin) .dependsOn( - common % "compile->compile;test->test;it->it" + common % "compile->compile;test->test;it->it", + metrics ) lazy val testkit = project.in(file("testkit")) @@ -111,7 +121,7 @@ lazy val api = project.in(file("api")) ) lazy val root = project.in(file(".")) - .aggregate(common, core, testkit, api) + .aggregate(common, metrics, core, testkit, api) .configs(IntegrationTest) .settings( Defaults.itSettings, diff --git a/core/src/main/scala/app/softnetwork/notification/persistence/typed/NotificationBehavior.scala b/core/src/main/scala/app/softnetwork/notification/persistence/typed/NotificationBehavior.scala index 83686c6..c928f96 100644 --- a/core/src/main/scala/app/softnetwork/notification/persistence/typed/NotificationBehavior.scala +++ b/core/src/main/scala/app/softnetwork/notification/persistence/typed/NotificationBehavior.scala @@ -18,6 +18,7 @@ import app.softnetwork.persistence.typed._ import app.softnetwork.notification.message._ import app.softnetwork.notification.model._ import app.softnetwork.notification.spi.NotificationProvider +import app.softnetwork.notification.metrics.NotificationMetrics import app.softnetwork.scheduler.config.SchedulerSettings import app.softnetwork.notification.model.NotificationStatus._ @@ -514,8 +515,13 @@ trait NotificationBehavior[T <: Notification] updatedNotification.status match { case Sent | Delivered => audit.event(cid, "notification_sent", "channel" -> channel) + // Story 13.9 — terminal-send metric. `template` is empty here: this layer has no + // bounded template identifier (see the note above) and a raw subject would explode + // the series cardinality; the producer owns template attribution on the enqueue side. + NotificationMetrics.notification(channel, "", "sent") case Undelivered | Rejected => audit.event(cid, "notification_failed", "channel" -> channel) + NotificationMetrics.notification(channel, "", "failed") case _ => // still pending — no terminal audit line yet } } diff --git a/metrics/build.sbt b/metrics/build.sbt new file mode 100644 index 0000000..e7fca97 --- /dev/null +++ b/metrics/build.sbt @@ -0,0 +1,10 @@ +organization := "app.softnetwork.notification" + +name := "notification-metrics" + +libraryDependencies ++= Seq( + // Lean, dependency-light module (Story 13.9): only the Prometheus client, so consumers + // (e.g. softclient4es-license-server) can depend on notification metrics WITHOUT pulling the + // full notification-common stack (firebase-admin, pushy, commons-email, ...). + "io.prometheus" % "prometheus-metrics-core" % Versions.prometheus +) diff --git a/metrics/src/main/scala/app/softnetwork/notification/metrics/NotificationMetrics.scala b/metrics/src/main/scala/app/softnetwork/notification/metrics/NotificationMetrics.scala new file mode 100644 index 0000000..91e2bb3 --- /dev/null +++ b/metrics/src/main/scala/app/softnetwork/notification/metrics/NotificationMetrics.scala @@ -0,0 +1,42 @@ +package app.softnetwork.notification.metrics + +import io.prometheus.metrics.core.metrics.Counter + +/** Notification delivery metrics. + * + * Lives in its own dependency-light `notification-metrics` module (Story 13.9) so consumers can + * depend on it without pulling the full `notification-common` stack. + * + * Registers into the global + * [[io.prometheus.metrics.model.registry.PrometheusRegistry#defaultRegistry]], so any process that + * exposes that registry on a `/metrics` route (e.g. the softclient4es license-server, Story 13.6) + * scrapes these series automatically — no wiring is required here. + * + * The counter name is declared WITHOUT the `_total` suffix — the Prometheus client appends it at + * exposition, so the exposed series is `notification_total`. + * + * Labels: + * - `service` — always `"notification"`. + * - `channel` — the `NotificationType` name (e.g. `MAIL_TYPE`, `SMS_TYPE`), matching the Story + * 13.7 audit `channel` field. + * - `template` — a BOUNDED template identifier, or empty when none is available at the call + * site. Never pass an unbounded free-text value (e.g. a raw subject): it would explode the + * series cardinality. The terminal send path (NotificationBehavior) has no template identifier + * and passes an empty value; the producer that enqueues the notification owns template + * attribution. + * - `outcome` — one of `enqueued` | `sent` | `failed` | `retried`. + */ +object NotificationMetrics { + + private val Service = "notification" + + private val notifications: Counter = Counter + .builder() + .name("notification") + .help("Notifications, by channel / template / outcome") + .labelNames("service", "channel", "template", "outcome") + .register() + + def notification(channel: String, template: String, outcome: String): Unit = + notifications.labelValues(Service, channel, template, outcome).inc() +} diff --git a/project/Versions.scala b/project/Versions.scala index f173e8d..66e834d 100644 --- a/project/Versions.scala +++ b/project/Versions.scala @@ -11,4 +11,6 @@ object Versions { val scheduler = "0.8-SNAPSHOT" val scalatest = "3.2.16" + + val prometheus = "1.7.0" }