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 be845f1..005a056 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 @@ -84,7 +92,6 @@ This setup is for actively developing the application. ``` The server will be running on `http://localhost`. - ## Configuration The application is configured using environment variables. diff --git a/build.sbt b/build.sbt index 951c6b0..3279bda 100644 --- a/build.sbt +++ b/build.sbt @@ -4,12 +4,13 @@ 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" lazy val htt4sVersion = "1.0.0-M39" lazy val logs4catVersion = "2.7.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") @@ -95,6 +96,14 @@ lazy val server = project "org.typelevel" %% "log4cats-core", "org.typelevel" %% "log4cats-slf4j" ).map(_ % logs4catVersion), + libraryDependencies ++= Seq( + "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" % "opentelemetry-exporter-prometheus" % "1.45.0-alpha" + ), libraryDependencies ++= Seq( "io.circe" %%% "circe-core", "io.circe" %%% "circe-generic", diff --git a/scripts/local-docker/docker-compose.yml b/scripts/local-docker/docker-compose.yml index 9b2497b..f88b924 100644 --- a/scripts/local-docker/docker-compose.yml +++ b/scripts/local-docker/docker-compose.yml @@ -24,11 +24,13 @@ 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: - caddy + ports: + - "9464:9464" environment: SERVER_PORT: 8080 DATASOURCE_URL: jdbc:postgresql://postgresdb:5432/rss @@ -38,4 +40,40 @@ services: CLIENT_ID: ${CLIENT_ID} CLIENT_SECRET: ${CLIENT_SECRET} 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 + 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 + - $PWD/grafana/provisioning:/etc/grafana/provisioning + depends_on: + - prometheus + +volumes: + grafana-storage: 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/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..18caf7a 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,8 @@ import com.zaxxer.hikari.HikariConfig import doobie.hikari.* import doobie.util.log.LogEvent import doobie.util.log.LogHandler +import io.opentelemetry.api.{OpenTelemetry => JOpenTelemetry} +import io.opentelemetry.instrumentation.runtimemetrics.java17.* import org.http4s.AuthedRoutes import org.http4s.HttpRoutes import org.http4s.StaticFile @@ -24,6 +26,8 @@ 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 import ru.trett.rss.server.authorization.SessionManager @@ -58,6 +62,11 @@ object Server extends IOApp: println(logEvent.sql) } + 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 { case Some(config) => config @@ -66,58 +75,80 @@ 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) - ) - ) - ).orNotFound - ) - .build - .use(_ => IO.never) - } yield server + OtelJava + .autoConfigured[IO]() + .flatTap(otel4s => registerRuntimeMetrics(otel4s.underlying)) + .evalTap(_ => logger.info("OpenTelemetry metrics initialized")) + .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 + ) + .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 + } + } } - } yield exitCode } - } private def loadConfig: Option[AppConfig] = ConfigSource.default.load[AppConfig] match { @@ -174,9 +205,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