Skip to content
Merged
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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.
Expand Down
11 changes: 10 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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",
Expand Down
40 changes: 39 additions & 1 deletion scripts/local-docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:

Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
apiVersion: 1

datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: true
10 changes: 10 additions & 0 deletions scripts/local-docker/prometheus.yml
Original file line number Diff line number Diff line change
@@ -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'
136 changes: 83 additions & 53 deletions server/src/main/scala/ru/trett/rss/server/Server.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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

Expand Down