From 3712d605753a6637e47a14a7cacbdb7a7a231cbb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 6 Dec 2025 12:50:37 +0000 Subject: [PATCH 01/10] Initial plan From c7905711979b8ae9f279efbeb737e3d1f2f41438 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 6 Dec 2025 13:01:07 +0000 Subject: [PATCH 02/10] Add OpenTelemetry metrics with JVM runtime instrumentation and Prometheus/Grafana support Co-authored-by: trett <1980024+trett@users.noreply.github.com> --- build.sbt | 11 ++ client/package-lock.json | 1 - scripts/local-docker/docker-compose.yml | 33 +++++ scripts/local-docker/prometheus.yml | 10 ++ .../scala/ru/trett/rss/server/Server.scala | 132 +++++++++++------- 5 files changed, 134 insertions(+), 53 deletions(-) create mode 100644 scripts/local-docker/prometheus.yml diff --git a/build.sbt b/build.sbt index 951c6b0..bc19dcd 100644 --- a/build.sbt +++ b/build.sbt @@ -10,6 +10,7 @@ lazy val scala3Version = "3.7.4" lazy val circeVersion = "0.14.15" lazy val htt4sVersion = "1.0.0-M39" lazy val logs4catVersion = "2.7.1" +lazy val otel4sVersion = "0.11.1" lazy val customScalaOptions = Seq("-Wunused:imports", "-rewrite", "-source:3.4-migration") lazy val buildClientDist = taskKey[File]("Build client optimized package") @@ -95,6 +96,16 @@ lazy val server = project "org.typelevel" %% "log4cats-core", "org.typelevel" %% "log4cats-slf4j" ).map(_ % logs4catVersion), + libraryDependencies ++= Seq( + "org.typelevel" %% "otel4s-oteljava", + "org.typelevel" %% "otel4s-oteljava-metrics" + ).map(_ % otel4sVersion), + libraryDependencies ++= Seq( + "io.opentelemetry" % "opentelemetry-exporter-prometheus" % "1.45.0-alpha", + "io.opentelemetry" % "opentelemetry-sdk" % "1.45.0", + "io.opentelemetry" % "opentelemetry-sdk-extension-autoconfigure" % "1.45.0", + "io.opentelemetry.instrumentation" % "opentelemetry-runtime-telemetry-java17" % "2.11.0-alpha" + ), libraryDependencies ++= Seq( "io.circe" %%% "circe-core", "io.circe" %%% "circe-generic", diff --git a/client/package-lock.json b/client/package-lock.json index b85b077..dbe6806 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1027,7 +1027,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/scripts/local-docker/docker-compose.yml b/scripts/local-docker/docker-compose.yml index 9b2497b..b19d2e1 100644 --- a/scripts/local-docker/docker-compose.yml +++ b/scripts/local-docker/docker-compose.yml @@ -29,6 +29,8 @@ services: restart: always depends_on: - caddy + ports: + - "9464:9464" environment: SERVER_PORT: 8080 DATASOURCE_URL: jdbc:postgresql://postgresdb:5432/rss @@ -39,3 +41,34 @@ services: CLIENT_SECRET: ${CLIENT_SECRET} GOOGLE_API_KEY: ${GOOGLE_API_KEY} + prometheus: + image: prom/prometheus:latest + container_name: prometheus + restart: always + ports: + - "9090:9090" + volumes: + - $PWD/prometheus.yml:/etc/prometheus/prometheus.yml + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + depends_on: + - server + + grafana: + image: grafana/grafana:latest + container_name: grafana + restart: always + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin + volumes: + - grafana-storage:/var/lib/grafana + depends_on: + - prometheus + +volumes: + grafana-storage: + diff --git a/scripts/local-docker/prometheus.yml b/scripts/local-docker/prometheus.yml new file mode 100644 index 0000000..31b94de --- /dev/null +++ b/scripts/local-docker/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: 'rss-server' + static_configs: + - targets: ['server:9464'] + labels: + service: 'rss-reader' diff --git a/server/src/main/scala/ru/trett/rss/server/Server.scala b/server/src/main/scala/ru/trett/rss/server/Server.scala index 678bb51..df2ca20 100644 --- a/server/src/main/scala/ru/trett/rss/server/Server.scala +++ b/server/src/main/scala/ru/trett/rss/server/Server.scala @@ -8,6 +8,10 @@ import com.zaxxer.hikari.HikariConfig import doobie.hikari.* import doobie.util.log.LogEvent import doobie.util.log.LogHandler +import io.opentelemetry.exporter.prometheus.PrometheusHttpServer +import io.opentelemetry.instrumentation.runtimemetrics.java17.RuntimeMetrics +import io.opentelemetry.sdk.OpenTelemetrySdk +import io.opentelemetry.sdk.metrics.SdkMeterProvider import org.http4s.AuthedRoutes import org.http4s.HttpRoutes import org.http4s.StaticFile @@ -58,6 +62,27 @@ object Server extends IOApp: println(logEvent.sql) } + private def initializeOpenTelemetry: Resource[IO, Unit] = + Resource.eval(IO { + val prometheusServer = PrometheusHttpServer + .builder() + .setPort(9464) + .build() + + val meterProvider = SdkMeterProvider + .builder() + .registerMetricReader(prometheusServer) + .build() + + val openTelemetry = OpenTelemetrySdk + .builder() + .setMeterProvider(meterProvider) + .build() + + // Register all JVM runtime metrics + RuntimeMetrics.builder(openTelemetry).enableAllFeatures().build() + }) + override def run(args: List[String]): IO[ExitCode] = val appConfig = loadConfig match { case Some(config) => config @@ -66,56 +91,60 @@ object Server extends IOApp: return IO.pure(ExitCode.Error) } - val client = EmberClientBuilder - .default[IO] - .build - transactor(appConfig.db).use { xa => - client.use { client => - for { - _ <- FlywayMigration.migrate(appConfig.db) - corsPolicy = createCorsPolicy(appConfig.cors) - sessionManager <- SessionManager[IO] - channelRepository = ChannelRepository(xa) - feedRepository = FeedRepository(xa) - feedService = FeedService(feedRepository) - userRepository = UserRepository(xa) - userService = UserService(userRepository) - summarizeService = new SummarizeService( - feedRepository, - client, - appConfig.google.apiKey - ) - channelService = ChannelService(channelRepository, client) - _ <- logger.info("Starting server on port: " + appConfig.server.port) - exitCode <- UpdateTask(channelService, userService).background.void.surround { - for { - authFilter <- AuthFilter[IO] - server <- EmberServerBuilder - .default[IO] - .withHost(ipv4"0.0.0.0") - .withPort(Port.fromInt(appConfig.server.port).get) - .withHttpApp( - withErrorLogging( - corsPolicy( - routes( - sessionManager, - channelService, - userService, - feedService, - appConfig.oauth, - authFilter, - client, - summarizeService, - new LogoutController[IO](sessionManager) - ) + initializeOpenTelemetry.use { _ => + val client = EmberClientBuilder + .default[IO] + .build + transactor(appConfig.db).use { xa => + client.use { client => + for { + _ <- FlywayMigration.migrate(appConfig.db) + _ <- logger.info("OpenTelemetry metrics initialized on port 9464") + corsPolicy = createCorsPolicy(appConfig.cors) + sessionManager <- SessionManager[IO] + channelRepository = ChannelRepository(xa) + feedRepository = FeedRepository(xa) + feedService = FeedService(feedRepository) + userRepository = UserRepository(xa) + userService = UserService(userRepository) + summarizeService = new SummarizeService( + feedRepository, + client, + appConfig.google.apiKey + ) + channelService = ChannelService(channelRepository, client) + _ <- logger.info("Starting server on port: " + appConfig.server.port) + exitCode <- UpdateTask(channelService, userService).background.void + .surround { + for { + authFilter <- AuthFilter[IO] + server <- EmberServerBuilder + .default[IO] + .withHost(ipv4"0.0.0.0") + .withPort(Port.fromInt(appConfig.server.port).get) + .withHttpApp( + withErrorLogging( + corsPolicy( + routes( + sessionManager, + channelService, + userService, + feedService, + appConfig.oauth, + authFilter, + client, + summarizeService, + new LogoutController[IO](sessionManager) + ) + ) + ).orNotFound ) - ).orNotFound - ) - .build - .use(_ => IO.never) - } yield server - } - } yield exitCode + .build + .use(_ => IO.never) + } yield server + } + } yield exitCode + } } } @@ -174,9 +203,8 @@ object Server extends IOApp: ) ) private def resourceRoutes: HttpRoutes[IO] = - val indexRoute = HttpRoutes.of[IO] { - case request @ GET -> Root => - StaticFile.fromResource("/public/index.html", Some(request)).getOrElseF(NotFound()) + val indexRoute = HttpRoutes.of[IO] { case request @ GET -> Root => + StaticFile.fromResource("/public/index.html", Some(request)).getOrElseF(NotFound()) } indexRoute <+> resourceServiceBuilder[IO]("/public").toRoutes From 367e9a3659560cec12abcac29c68cfee3a8c5f6f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 6 Dec 2025 13:02:36 +0000 Subject: [PATCH 03/10] Update README with OpenTelemetry metrics and monitoring documentation Co-authored-by: trett <1980024+trett@users.noreply.github.com> --- README.md | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index be845f1..366913a 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A modern, web-based RSS reader application built with Scala, Scala.js, and Lamin - **AI-Powered Summaries**: Generate summaries of all your unread articles using Google's Generative AI. - **Secure Authentication**: Authentication is handled securely via Google OAuth2. - **Responsive Design**: The application is designed to work on both desktop and mobile browsers. +- **Observability**: Built-in metrics collection with OpenTelemetry, exportable to Prometheus and visualizable in Grafana. ## Tech Stack @@ -24,6 +25,9 @@ A modern, web-based RSS reader application built with Scala, Scala.js, and Lamin - [Flyway](https://flywaydb.org/): For database migrations. - [circe](https://circe.github.io/circe/): For JSON manipulation. - [PureConfig](https://pureconfig.github.io/): For loading configuration. +- [OpenTelemetry](https://opentelemetry.io/): For metrics collection and observability. +- [Prometheus](https://prometheus.io/): For metrics storage and querying. +- [Grafana](https://grafana.com/): For metrics visualization and dashboards. ### Frontend @@ -64,7 +68,11 @@ This is the easiest way to run the application. ```bash docker-compose -f scripts/local-docker/docker-compose.yml up ``` - The application will be available at `http://localhost`. + The application will be available at: + - Main app: `http://localhost` + - Prometheus: `http://localhost:9090` + - Grafana: `http://localhost:3000` (default credentials: admin/admin) + - Metrics endpoint: `http://localhost:9464/metrics` ### Local Development @@ -85,6 +93,60 @@ This setup is for actively developing the application. The server will be running on `http://localhost`. +## Metrics and Monitoring + +The application includes built-in observability features powered by OpenTelemetry: + +### JVM Runtime Metrics + +The server automatically collects and exposes JVM runtime metrics including: +- **Memory**: Heap and non-heap memory usage, memory pools +- **Garbage Collection**: GC duration and frequency +- **Threads**: Thread count and state +- **CPU**: Process CPU usage +- **Classes**: Loaded and unloaded class counts +- **Buffer Pools**: Direct and mapped buffer pool statistics + +### Accessing Metrics + +Metrics are exposed in Prometheus format at: +``` +http://localhost:9464/metrics +``` + +### Prometheus and Grafana + +When running with Docker Compose, the stack includes: + +- **Prometheus**: Scrapes metrics from the server every 15 seconds + - Access at: `http://localhost:9090` + - Pre-configured to scrape the server metrics endpoint + +- **Grafana**: Visualizes metrics with dashboards + - Access at: `http://localhost:3000` + - Default credentials: `admin/admin` + - To view metrics, add Prometheus as a data source: + 1. Go to Configuration → Data Sources + 2. Add Prometheus with URL: `http://prometheus:9090` + 3. Create dashboards to visualize JVM metrics + +### Example Prometheus Queries + +```promql +# JVM Memory Usage +process_runtime_jvm_memory_usage{type="heap"} + +# GC Duration +process_runtime_jvm_gc_duration_sum + +# Thread Count +process_runtime_jvm_threads_count + +# CPU Usage +process_runtime_jvm_cpu_utilization +``` + + ## Configuration The application is configured using environment variables. From e35a742e41ce849e0a9df3098c673c6d7bbfcfbc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 6 Dec 2025 13:06:16 +0000 Subject: [PATCH 04/10] Address code review feedback: configurable metrics port and proper resource lifecycle Co-authored-by: trett <1980024+trett@users.noreply.github.com> --- server/src/main/resources/application.conf | 5 +++++ .../scala/ru/trett/rss/server/Server.scala | 20 +++++++++++++------ .../trett/rss/server/config/AppConfig.scala | 5 ++++- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/server/src/main/resources/application.conf b/server/src/main/resources/application.conf index 76fc97d..c3c1ab6 100644 --- a/server/src/main/resources/application.conf +++ b/server/src/main/resources/application.conf @@ -32,3 +32,8 @@ google { api-key = "" api-key = ${?GOOGLE_API_KEY} } + +metrics { + port = 9464 + port = ${?METRICS_PORT} +} diff --git a/server/src/main/scala/ru/trett/rss/server/Server.scala b/server/src/main/scala/ru/trett/rss/server/Server.scala index df2ca20..6284141 100644 --- a/server/src/main/scala/ru/trett/rss/server/Server.scala +++ b/server/src/main/scala/ru/trett/rss/server/Server.scala @@ -62,11 +62,11 @@ object Server extends IOApp: println(logEvent.sql) } - private def initializeOpenTelemetry: Resource[IO, Unit] = - Resource.eval(IO { + private def initializeOpenTelemetry(metricsPort: Int): Resource[IO, OpenTelemetrySdk] = + Resource.make(IO { val prometheusServer = PrometheusHttpServer .builder() - .setPort(9464) + .setPort(metricsPort) .build() val meterProvider = SdkMeterProvider @@ -81,7 +81,13 @@ object Server extends IOApp: // Register all JVM runtime metrics RuntimeMetrics.builder(openTelemetry).enableAllFeatures().build() - }) + + openTelemetry + })(otel => + IO { + otel.close() + } + ) override def run(args: List[String]): IO[ExitCode] = val appConfig = loadConfig match { @@ -91,7 +97,7 @@ object Server extends IOApp: return IO.pure(ExitCode.Error) } - initializeOpenTelemetry.use { _ => + initializeOpenTelemetry(appConfig.metrics.port).use { _ => val client = EmberClientBuilder .default[IO] .build @@ -99,7 +105,9 @@ object Server extends IOApp: client.use { client => for { _ <- FlywayMigration.migrate(appConfig.db) - _ <- logger.info("OpenTelemetry metrics initialized on port 9464") + _ <- logger.info( + s"OpenTelemetry metrics initialized on port ${appConfig.metrics.port}" + ) corsPolicy = createCorsPolicy(appConfig.cors) sessionManager <- SessionManager[IO] channelRepository = ChannelRepository(xa) diff --git a/server/src/main/scala/ru/trett/rss/server/config/AppConfig.scala b/server/src/main/scala/ru/trett/rss/server/config/AppConfig.scala index 569b1f8..19b7968 100644 --- a/server/src/main/scala/ru/trett/rss/server/config/AppConfig.scala +++ b/server/src/main/scala/ru/trett/rss/server/config/AppConfig.scala @@ -9,7 +9,8 @@ case class AppConfig( db: DbConfig, oauth: OAuthConfig, cors: CorsConfig, - google: GoogleConfig + google: GoogleConfig, + metrics: MetricsConfig ) derives ConfigReader case class ServerConfig(port: Int, host: String = "0.0.0.0") derives ConfigReader @@ -24,3 +25,5 @@ case class CorsConfig(allowedOrigin: String, allowCredentials: Boolean, maxAge: derives ConfigReader case class GoogleConfig(apiKey: String) derives ConfigReader + +case class MetricsConfig(port: Int = 9464) derives ConfigReader From da4c012818b6349b5c2419dffb6caee8151abced Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 6 Dec 2025 13:07:02 +0000 Subject: [PATCH 05/10] Document METRICS_PORT environment variable in README Co-authored-by: trett <1980024+trett@users.noreply.github.com> --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 366913a..3db6058 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,7 @@ The application is configured using environment variables. | `SERVER_URL` | The public URL of the server. Used for OAuth redirect URI. | `https://localhost` | No | | `CORS_URL` | The allowed origin for CORS requests. | `https://localhost` | No | | `GOOGLE_API_KEY` | The API key for Google's Generative AI. | - | For summary feature | +| `METRICS_PORT` | The port for OpenTelemetry metrics endpoint (Prometheus format). | `9464` | No | | `REGISTRY` | The Docker registry to push the image to | - | No | ## Deployment From 5a413f3e134937444f7afa6fcb44021b0b8b2dc4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 6 Dec 2025 13:26:25 +0000 Subject: [PATCH 06/10] Simplify OpenTelemetry implementation using OtelJava.autoConfigured and update to latest library versions Co-authored-by: trett <1980024+trett@users.noreply.github.com> --- README.md | 55 +------ build.sbt | 10 +- scripts/local-docker/docker-compose.yml | 2 + server/src/main/resources/application.conf | 5 - .../scala/ru/trett/rss/server/Server.scala | 151 ++++++++---------- .../trett/rss/server/config/AppConfig.scala | 5 +- 6 files changed, 77 insertions(+), 151 deletions(-) diff --git a/README.md b/README.md index 3db6058..41fb769 100644 --- a/README.md +++ b/README.md @@ -95,56 +95,12 @@ This setup is for actively developing the application. ## Metrics and Monitoring -The application includes built-in observability features powered by OpenTelemetry: +The application includes OpenTelemetry metrics collection with Prometheus and Grafana support. -### JVM Runtime Metrics - -The server automatically collects and exposes JVM runtime metrics including: -- **Memory**: Heap and non-heap memory usage, memory pools -- **Garbage Collection**: GC duration and frequency -- **Threads**: Thread count and state -- **CPU**: Process CPU usage -- **Classes**: Loaded and unloaded class counts -- **Buffer Pools**: Direct and mapped buffer pool statistics - -### Accessing Metrics - -Metrics are exposed in Prometheus format at: -``` -http://localhost:9464/metrics -``` - -### Prometheus and Grafana - -When running with Docker Compose, the stack includes: - -- **Prometheus**: Scrapes metrics from the server every 15 seconds - - Access at: `http://localhost:9090` - - Pre-configured to scrape the server metrics endpoint - -- **Grafana**: Visualizes metrics with dashboards - - Access at: `http://localhost:3000` - - Default credentials: `admin/admin` - - To view metrics, add Prometheus as a data source: - 1. Go to Configuration → Data Sources - 2. Add Prometheus with URL: `http://prometheus:9090` - 3. Create dashboards to visualize JVM metrics - -### Example Prometheus Queries - -```promql -# JVM Memory Usage -process_runtime_jvm_memory_usage{type="heap"} - -# GC Duration -process_runtime_jvm_gc_duration_sum - -# Thread Count -process_runtime_jvm_threads_count - -# CPU Usage -process_runtime_jvm_cpu_utilization -``` +When running with Docker Compose: +- **Prometheus**: `http://localhost:9090` +- **Grafana**: `http://localhost:3000` (admin/admin) +- **Metrics endpoint**: `http://localhost:9464/metrics` ## Configuration @@ -162,7 +118,6 @@ The application is configured using environment variables. | `SERVER_URL` | The public URL of the server. Used for OAuth redirect URI. | `https://localhost` | No | | `CORS_URL` | The allowed origin for CORS requests. | `https://localhost` | No | | `GOOGLE_API_KEY` | The API key for Google's Generative AI. | - | For summary feature | -| `METRICS_PORT` | The port for OpenTelemetry metrics endpoint (Prometheus format). | `9464` | No | | `REGISTRY` | The Docker registry to push the image to | - | No | ## Deployment diff --git a/build.sbt b/build.sbt index bc19dcd..cdfeb09 100644 --- a/build.sbt +++ b/build.sbt @@ -10,7 +10,7 @@ lazy val scala3Version = "3.7.4" lazy val circeVersion = "0.14.15" lazy val htt4sVersion = "1.0.0-M39" lazy val logs4catVersion = "2.7.1" -lazy val otel4sVersion = "0.11.1" +lazy val otel4sVersion = "0.14.0" lazy val customScalaOptions = Seq("-Wunused:imports", "-rewrite", "-source:3.4-migration") lazy val buildClientDist = taskKey[File]("Build client optimized package") @@ -97,14 +97,10 @@ lazy val server = project "org.typelevel" %% "log4cats-slf4j" ).map(_ % logs4catVersion), libraryDependencies ++= Seq( - "org.typelevel" %% "otel4s-oteljava", - "org.typelevel" %% "otel4s-oteljava-metrics" + "org.typelevel" %% "otel4s-oteljava" ).map(_ % otel4sVersion), libraryDependencies ++= Seq( - "io.opentelemetry" % "opentelemetry-exporter-prometheus" % "1.45.0-alpha", - "io.opentelemetry" % "opentelemetry-sdk" % "1.45.0", - "io.opentelemetry" % "opentelemetry-sdk-extension-autoconfigure" % "1.45.0", - "io.opentelemetry.instrumentation" % "opentelemetry-runtime-telemetry-java17" % "2.11.0-alpha" + "io.opentelemetry.instrumentation" % "opentelemetry-runtime-telemetry-java17" % "2.22.0-alpha" ), libraryDependencies ++= Seq( "io.circe" %%% "circe-core", diff --git a/scripts/local-docker/docker-compose.yml b/scripts/local-docker/docker-compose.yml index b19d2e1..ccb244e 100644 --- a/scripts/local-docker/docker-compose.yml +++ b/scripts/local-docker/docker-compose.yml @@ -40,6 +40,8 @@ services: CLIENT_ID: ${CLIENT_ID} CLIENT_SECRET: ${CLIENT_SECRET} GOOGLE_API_KEY: ${GOOGLE_API_KEY} + OTEL_EXPORTER_PROMETHEUS_PORT: 9464 + OTEL_METRICS_EXPORTER: prometheus prometheus: image: prom/prometheus:latest diff --git a/server/src/main/resources/application.conf b/server/src/main/resources/application.conf index c3c1ab6..76fc97d 100644 --- a/server/src/main/resources/application.conf +++ b/server/src/main/resources/application.conf @@ -32,8 +32,3 @@ google { api-key = "" api-key = ${?GOOGLE_API_KEY} } - -metrics { - port = 9464 - port = ${?METRICS_PORT} -} diff --git a/server/src/main/scala/ru/trett/rss/server/Server.scala b/server/src/main/scala/ru/trett/rss/server/Server.scala index 6284141..97ecbb8 100644 --- a/server/src/main/scala/ru/trett/rss/server/Server.scala +++ b/server/src/main/scala/ru/trett/rss/server/Server.scala @@ -3,15 +3,15 @@ package ru.trett.rss.server import cats.data.OptionT import cats.effect.* import cats.implicits.* +import cats.syntax.flatMap.* +import cats.syntax.functor.* import com.comcast.ip4s.* import com.zaxxer.hikari.HikariConfig import doobie.hikari.* import doobie.util.log.LogEvent import doobie.util.log.LogHandler -import io.opentelemetry.exporter.prometheus.PrometheusHttpServer -import io.opentelemetry.instrumentation.runtimemetrics.java17.RuntimeMetrics -import io.opentelemetry.sdk.OpenTelemetrySdk -import io.opentelemetry.sdk.metrics.SdkMeterProvider +import io.opentelemetry.api.{OpenTelemetry => JOpenTelemetry} +import io.opentelemetry.instrumentation.runtimemetrics.java17.* import org.http4s.AuthedRoutes import org.http4s.HttpRoutes import org.http4s.StaticFile @@ -28,6 +28,7 @@ import org.http4s.server.middleware.ErrorHandling import org.http4s.server.staticcontent.* import org.typelevel.log4cats.* import org.typelevel.log4cats.slf4j.* +import org.typelevel.otel4s.oteljava.OtelJava import pureconfig.ConfigSource import ru.trett.rss.server.authorization.AuthFilter import ru.trett.rss.server.authorization.SessionManager @@ -62,32 +63,10 @@ object Server extends IOApp: println(logEvent.sql) } - private def initializeOpenTelemetry(metricsPort: Int): Resource[IO, OpenTelemetrySdk] = - Resource.make(IO { - val prometheusServer = PrometheusHttpServer - .builder() - .setPort(metricsPort) - .build() - - val meterProvider = SdkMeterProvider - .builder() - .registerMetricReader(prometheusServer) - .build() - - val openTelemetry = OpenTelemetrySdk - .builder() - .setMeterProvider(meterProvider) - .build() - - // Register all JVM runtime metrics - RuntimeMetrics.builder(openTelemetry).enableAllFeatures().build() - - openTelemetry - })(otel => - IO { - otel.close() - } - ) + private def registerRuntimeMetrics(openTelemetry: JOpenTelemetry): Resource[IO, Unit] = { + val acquire = IO.delay(RuntimeMetrics.create(openTelemetry)) + Resource.fromAutoCloseable(acquire).void + } override def run(args: List[String]): IO[ExitCode] = val appConfig = loadConfig match { @@ -97,64 +76,66 @@ object Server extends IOApp: return IO.pure(ExitCode.Error) } - initializeOpenTelemetry(appConfig.metrics.port).use { _ => - val client = EmberClientBuilder - .default[IO] - .build - transactor(appConfig.db).use { xa => - client.use { client => - for { - _ <- FlywayMigration.migrate(appConfig.db) - _ <- logger.info( - s"OpenTelemetry metrics initialized on port ${appConfig.metrics.port}" - ) - corsPolicy = createCorsPolicy(appConfig.cors) - sessionManager <- SessionManager[IO] - channelRepository = ChannelRepository(xa) - feedRepository = FeedRepository(xa) - feedService = FeedService(feedRepository) - userRepository = UserRepository(xa) - userService = UserService(userRepository) - summarizeService = new SummarizeService( - feedRepository, - client, - appConfig.google.apiKey - ) - channelService = ChannelService(channelRepository, client) - _ <- logger.info("Starting server on port: " + appConfig.server.port) - exitCode <- UpdateTask(channelService, userService).background.void - .surround { - for { - authFilter <- AuthFilter[IO] - server <- EmberServerBuilder - .default[IO] - .withHost(ipv4"0.0.0.0") - .withPort(Port.fromInt(appConfig.server.port).get) - .withHttpApp( - withErrorLogging( - corsPolicy( - routes( - sessionManager, - channelService, - userService, - feedService, - appConfig.oauth, - authFilter, - client, - summarizeService, - new LogoutController[IO](sessionManager) + OtelJava + .autoConfigured[IO]() + .flatTap(otel4s => registerRuntimeMetrics(otel4s.underlying)) + .use { otel4s => + val _ = otel4s + val client = EmberClientBuilder + .default[IO] + .build + transactor(appConfig.db).use { xa => + client.use { client => + for { + _ <- FlywayMigration.migrate(appConfig.db) + _ <- logger.info("OpenTelemetry metrics initialized") + corsPolicy = createCorsPolicy(appConfig.cors) + sessionManager <- SessionManager[IO] + channelRepository = ChannelRepository(xa) + feedRepository = FeedRepository(xa) + feedService = FeedService(feedRepository) + userRepository = UserRepository(xa) + userService = UserService(userRepository) + summarizeService = new SummarizeService( + feedRepository, + client, + appConfig.google.apiKey + ) + channelService = ChannelService(channelRepository, client) + _ <- logger.info("Starting server on port: " + appConfig.server.port) + exitCode <- UpdateTask(channelService, userService).background.void + .surround { + for { + authFilter <- AuthFilter[IO] + server <- EmberServerBuilder + .default[IO] + .withHost(ipv4"0.0.0.0") + .withPort(Port.fromInt(appConfig.server.port).get) + .withHttpApp( + withErrorLogging( + corsPolicy( + routes( + sessionManager, + channelService, + userService, + feedService, + appConfig.oauth, + authFilter, + client, + summarizeService, + new LogoutController[IO](sessionManager) + ) ) - ) - ).orNotFound - ) - .build - .use(_ => IO.never) - } yield server - } - } yield exitCode + ).orNotFound + ) + .build + .use(_ => IO.never) + } yield server + } + } yield exitCode + } } } - } private def loadConfig: Option[AppConfig] = ConfigSource.default.load[AppConfig] match { diff --git a/server/src/main/scala/ru/trett/rss/server/config/AppConfig.scala b/server/src/main/scala/ru/trett/rss/server/config/AppConfig.scala index 19b7968..569b1f8 100644 --- a/server/src/main/scala/ru/trett/rss/server/config/AppConfig.scala +++ b/server/src/main/scala/ru/trett/rss/server/config/AppConfig.scala @@ -9,8 +9,7 @@ case class AppConfig( db: DbConfig, oauth: OAuthConfig, cors: CorsConfig, - google: GoogleConfig, - metrics: MetricsConfig + google: GoogleConfig ) derives ConfigReader case class ServerConfig(port: Int, host: String = "0.0.0.0") derives ConfigReader @@ -25,5 +24,3 @@ case class CorsConfig(allowedOrigin: String, allowCredentials: Boolean, maxAge: derives ConfigReader case class GoogleConfig(apiKey: String) derives ConfigReader - -case class MetricsConfig(port: Int = 9464) derives ConfigReader From fb960c55c60a8229d5ddcbfb605489512b181497 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 6 Dec 2025 13:28:23 +0000 Subject: [PATCH 07/10] Remove unused variable assignment Co-authored-by: trett <1980024+trett@users.noreply.github.com> --- server/src/main/scala/ru/trett/rss/server/Server.scala | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/server/src/main/scala/ru/trett/rss/server/Server.scala b/server/src/main/scala/ru/trett/rss/server/Server.scala index 97ecbb8..22be067 100644 --- a/server/src/main/scala/ru/trett/rss/server/Server.scala +++ b/server/src/main/scala/ru/trett/rss/server/Server.scala @@ -79,8 +79,7 @@ object Server extends IOApp: OtelJava .autoConfigured[IO]() .flatTap(otel4s => registerRuntimeMetrics(otel4s.underlying)) - .use { otel4s => - val _ = otel4s + .use { _ => val client = EmberClientBuilder .default[IO] .build From 20b6869907702c84df08ef29c63e0616213fa952 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 6 Dec 2025 13:44:48 +0000 Subject: [PATCH 08/10] Apply PR review feedback: remove redundant imports, fix log placement, add Grafana provisioning Co-authored-by: trett <1980024+trett@users.noreply.github.com> --- scripts/local-docker/docker-compose.yml | 1 + .../grafana/provisioning/datasources/prometheus.yml | 9 +++++++++ server/src/main/scala/ru/trett/rss/server/Server.scala | 4 +--- 3 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 scripts/local-docker/grafana/provisioning/datasources/prometheus.yml diff --git a/scripts/local-docker/docker-compose.yml b/scripts/local-docker/docker-compose.yml index ccb244e..aa82d79 100644 --- a/scripts/local-docker/docker-compose.yml +++ b/scripts/local-docker/docker-compose.yml @@ -68,6 +68,7 @@ services: - GF_SECURITY_ADMIN_PASSWORD=admin volumes: - grafana-storage:/var/lib/grafana + - $PWD/grafana/provisioning:/etc/grafana/provisioning depends_on: - prometheus diff --git a/scripts/local-docker/grafana/provisioning/datasources/prometheus.yml b/scripts/local-docker/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 0000000..1a57b69 --- /dev/null +++ b/scripts/local-docker/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,9 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: true diff --git a/server/src/main/scala/ru/trett/rss/server/Server.scala b/server/src/main/scala/ru/trett/rss/server/Server.scala index 22be067..e61ad5e 100644 --- a/server/src/main/scala/ru/trett/rss/server/Server.scala +++ b/server/src/main/scala/ru/trett/rss/server/Server.scala @@ -3,8 +3,6 @@ package ru.trett.rss.server import cats.data.OptionT import cats.effect.* import cats.implicits.* -import cats.syntax.flatMap.* -import cats.syntax.functor.* import com.comcast.ip4s.* import com.zaxxer.hikari.HikariConfig import doobie.hikari.* @@ -79,6 +77,7 @@ object Server extends IOApp: OtelJava .autoConfigured[IO]() .flatTap(otel4s => registerRuntimeMetrics(otel4s.underlying)) + .evalTap(_ => logger.info("OpenTelemetry metrics initialized")) .use { _ => val client = EmberClientBuilder .default[IO] @@ -87,7 +86,6 @@ object Server extends IOApp: client.use { client => for { _ <- FlywayMigration.migrate(appConfig.db) - _ <- logger.info("OpenTelemetry metrics initialized") corsPolicy = createCorsPolicy(appConfig.cors) sessionManager <- SessionManager[IO] channelRepository = ChannelRepository(xa) From 385c162db62ab057f7a7b7f413c3abca711363a9 Mon Sep 17 00:00:00 2001 From: trett Date: Sat, 6 Dec 2025 16:15:40 +0100 Subject: [PATCH 09/10] enable cats-effect metrics --- AGENTS.md | 10 ++ README.md | 11 -- build.sbt | 6 +- client/package-lock.json | 1 + scripts/local-docker/docker-compose.yml | 2 + .../scala/ru/trett/rss/server/Server.scala | 120 ++++++++++-------- 6 files changed, 85 insertions(+), 65 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 688a492..0e83280 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,16 @@ The project uses a hybrid build system: - **npm/Vite** is used for the frontend development workflow. `package.json` in the `client` directory defines frontend dependencies and scripts. Vite is used for the development server and for bundling frontend assets for production. - building the frontend is integrated into the sbt build process using the `@scala-js/vite-plugin-scalajs` plugin. `sbt client/fullOptJS`command uses to build the optimized frontend assets. - there are Scalafmt and Scalastyle configurations for code formatting and style checking. + +## Language + +The project uses **Scala 3**. Always use Scala 3 syntax and keywords: +- Use `given` instead of `implicit` for implicit values and conversions +- Use `using` instead of `implicit` for context parameters +- Use the new control structure syntax (optional braces when appropriate) +- Use `extension` methods instead of implicit classes +- Prefer end markers for better readability in longer code blocks + ## Frameworks & Libraries ### Backend (Server) diff --git a/README.md b/README.md index 41fb769..005a056 100644 --- a/README.md +++ b/README.md @@ -92,17 +92,6 @@ This setup is for actively developing the application. ``` The server will be running on `http://localhost`. - -## Metrics and Monitoring - -The application includes OpenTelemetry metrics collection with Prometheus and Grafana support. - -When running with Docker Compose: -- **Prometheus**: `http://localhost:9090` -- **Grafana**: `http://localhost:3000` (admin/admin) -- **Metrics endpoint**: `http://localhost:9464/metrics` - - ## Configuration The application is configured using environment variables. diff --git a/build.sbt b/build.sbt index cdfeb09..8152857 100644 --- a/build.sbt +++ b/build.sbt @@ -97,10 +97,12 @@ lazy val server = project "org.typelevel" %% "log4cats-slf4j" ).map(_ % logs4catVersion), libraryDependencies ++= Seq( - "org.typelevel" %% "otel4s-oteljava" + "org.typelevel" %% "otel4s-oteljava", + "org.typelevel" %% "otel4s-instrumentation-metrics" ).map(_ % otel4sVersion), libraryDependencies ++= Seq( - "io.opentelemetry.instrumentation" % "opentelemetry-runtime-telemetry-java17" % "2.22.0-alpha" + "io.opentelemetry.instrumentation" % "opentelemetry-runtime-telemetry-java17" % "2.22.0-alpha", + "io.opentelemetry" % "opentelemetry-exporter-prometheus" % "1.45.0-alpha" ), libraryDependencies ++= Seq( "io.circe" %%% "circe-core", diff --git a/client/package-lock.json b/client/package-lock.json index dbe6806..b85b077 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1027,6 +1027,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/scripts/local-docker/docker-compose.yml b/scripts/local-docker/docker-compose.yml index aa82d79..6185bd7 100644 --- a/scripts/local-docker/docker-compose.yml +++ b/scripts/local-docker/docker-compose.yml @@ -42,6 +42,8 @@ services: GOOGLE_API_KEY: ${GOOGLE_API_KEY} OTEL_EXPORTER_PROMETHEUS_PORT: 9464 OTEL_METRICS_EXPORTER: prometheus + OTEL_TRACES_EXPORTER: none + OTEL_LOGS_EXPORTER: none prometheus: image: prom/prometheus:latest diff --git a/server/src/main/scala/ru/trett/rss/server/Server.scala b/server/src/main/scala/ru/trett/rss/server/Server.scala index e61ad5e..18caf7a 100644 --- a/server/src/main/scala/ru/trett/rss/server/Server.scala +++ b/server/src/main/scala/ru/trett/rss/server/Server.scala @@ -26,6 +26,7 @@ import org.http4s.server.middleware.ErrorHandling import org.http4s.server.staticcontent.* import org.typelevel.log4cats.* import org.typelevel.log4cats.slf4j.* +import org.typelevel.otel4s.instrumentation.ce.IORuntimeMetrics import org.typelevel.otel4s.oteljava.OtelJava import pureconfig.ConfigSource import ru.trett.rss.server.authorization.AuthFilter @@ -78,60 +79,75 @@ object Server extends IOApp: .autoConfigured[IO]() .flatTap(otel4s => registerRuntimeMetrics(otel4s.underlying)) .evalTap(_ => logger.info("OpenTelemetry metrics initialized")) - .use { _ => - val client = EmberClientBuilder - .default[IO] - .build - transactor(appConfig.db).use { xa => - client.use { client => - for { - _ <- FlywayMigration.migrate(appConfig.db) - corsPolicy = createCorsPolicy(appConfig.cors) - sessionManager <- SessionManager[IO] - channelRepository = ChannelRepository(xa) - feedRepository = FeedRepository(xa) - feedService = FeedService(feedRepository) - userRepository = UserRepository(xa) - userService = UserService(userRepository) - summarizeService = new SummarizeService( - feedRepository, - client, - appConfig.google.apiKey - ) - channelService = ChannelService(channelRepository, client) - _ <- logger.info("Starting server on port: " + appConfig.server.port) - exitCode <- UpdateTask(channelService, userService).background.void - .surround { - for { - authFilter <- AuthFilter[IO] - server <- EmberServerBuilder - .default[IO] - .withHost(ipv4"0.0.0.0") - .withPort(Port.fromInt(appConfig.server.port).get) - .withHttpApp( - withErrorLogging( - corsPolicy( - routes( - sessionManager, - channelService, - userService, - feedService, - appConfig.oauth, - authFilter, - client, - summarizeService, - new LogoutController[IO](sessionManager) - ) + .use { otel4s => + given org.typelevel.otel4s.metrics.MeterProvider[IO] = + otel4s.meterProvider + IORuntimeMetrics + .register[IO](runtime.metrics, IORuntimeMetrics.Config.default) + .surround { + val client = EmberClientBuilder + .default[IO] + .build + transactor(appConfig.db).use { xa => + client.use { client => + for { + _ <- FlywayMigration.migrate(appConfig.db) + corsPolicy = createCorsPolicy(appConfig.cors) + sessionManager <- SessionManager[IO] + channelRepository = ChannelRepository(xa) + feedRepository = FeedRepository(xa) + feedService = FeedService(feedRepository) + userRepository = UserRepository(xa) + userService = UserService(userRepository) + summarizeService = new SummarizeService( + feedRepository, + client, + appConfig.google.apiKey + ) + channelService = ChannelService(channelRepository, client) + _ <- logger.info( + "Starting server on port: " + appConfig.server.port + ) + exitCode <- UpdateTask( + channelService, + userService + ).background.void + .surround { + for { + authFilter <- AuthFilter[IO] + server <- EmberServerBuilder + .default[IO] + .withHost(ipv4"0.0.0.0") + .withPort( + Port.fromInt(appConfig.server.port).get ) - ).orNotFound - ) - .build - .use(_ => IO.never) - } yield server - } - } yield exitCode + .withHttpApp( + withErrorLogging( + corsPolicy( + routes( + sessionManager, + channelService, + userService, + feedService, + appConfig.oauth, + authFilter, + client, + summarizeService, + new LogoutController[IO]( + sessionManager + ) + ) + ) + ).orNotFound + ) + .build + .use(_ => IO.never) + } yield server + } + } yield exitCode + } + } } - } } private def loadConfig: Option[AppConfig] = From 1caede3b8ec4715b5aede91996d6c867ec1ea8aa Mon Sep 17 00:00:00 2001 From: trett Date: Sat, 6 Dec 2025 16:17:08 +0100 Subject: [PATCH 10/10] bump version --- build.sbt | 2 +- scripts/local-docker/docker-compose.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/build.sbt b/build.sbt index 8152857..3279bda 100644 --- a/build.sbt +++ b/build.sbt @@ -4,7 +4,7 @@ import org.scalajs.linker.interface.ModuleSplitStyle import scala.sys.process.* -lazy val projectVersion = "2.2.9" +lazy val projectVersion = "2.3.0" lazy val organizationName = "ru.trett" lazy val scala3Version = "3.7.4" lazy val circeVersion = "0.14.15" diff --git a/scripts/local-docker/docker-compose.yml b/scripts/local-docker/docker-compose.yml index 6185bd7..f88b924 100644 --- a/scripts/local-docker/docker-compose.yml +++ b/scripts/local-docker/docker-compose.yml @@ -24,7 +24,7 @@ services: - host.docker.internal:host-gateway server: - image: server:2.2.9 + image: server:2.3.0 container_name: rss_server restart: always depends_on: