Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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"))
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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._

Expand Down Expand Up @@ -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
}
}
Expand Down
10 changes: 10 additions & 0 deletions metrics/build.sbt
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
@@ -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()
}
2 changes: 2 additions & 0 deletions project/Versions.scala
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ object Versions {
val scheduler = "0.8-SNAPSHOT"

val scalatest = "3.2.16"

val prometheus = "1.7.0"
}
Loading