From ac1c35bb5ecb012f54ea24c35f4c9ad02cba9afe Mon Sep 17 00:00:00 2001 From: Cedric Grard Date: Fri, 5 Dec 2025 10:50:00 +0100 Subject: [PATCH 01/11] Add Prometheus metrics integration for monitoring and observability --- Gemfile | 3 + PROMETHEUS.md | 466 ++++++++++++++++++++++ Procfile | 1 + Procfile.dev | 1 + app/models/audit_log.rb | 16 +- app/models/concerns/prometheus_metrics.rb | 49 +++ app/models/push.rb | 6 + config/initializers/prometheus.rb | 83 ++++ config/prometheus_server.rb | 74 ++++ config/routes.rb | 6 + 10 files changed, 704 insertions(+), 1 deletion(-) create mode 100644 PROMETHEUS.md create mode 100644 app/models/concerns/prometheus_metrics.rb create mode 100644 config/initializers/prometheus.rb create mode 100644 config/prometheus_server.rb diff --git a/Gemfile b/Gemfile index bd6a4a5a0d9..7978a8703c3 100644 --- a/Gemfile +++ b/Gemfile @@ -116,3 +116,6 @@ gem "mission_control-jobs", "~> 1.1.0" gem "overmind", "~> 2.5", group: :development gem "dotenv", "~> 3.2" + +# Prometheus metrics exporter +gem "prometheus_exporter" diff --git a/PROMETHEUS.md b/PROMETHEUS.md new file mode 100644 index 00000000000..ae5e8b76a3b --- /dev/null +++ b/PROMETHEUS.md @@ -0,0 +1,466 @@ +# Prometheus Metrics + +Password Pusher includes built-in Prometheus metrics export for monitoring and observability. + +## Quick Start + +Prometheus metrics are integrated into Password Pusher and start automatically with the application. + +### 1. Install dependencies + +```bash +bundle install +``` + +### 2. Start the application + +```bash +# Development +foreman start -f Procfile.dev + +# Production +foreman start +``` + +The Prometheus exporter starts automatically as part of the application stack. + +### 3. Access metrics + +Metrics are available at: **`http://localhost:9394/metrics`** + +## Architecture + +The Prometheus integration uses `prometheus_exporter` with two components: + +1. **Exporter Server Process**: Automatically started via Procfile, collects and serves metrics on port 9394 +2. **Client Instrumentation**: Middleware and callbacks in the Rails app that send metrics to the exporter + +Both components start automatically when you run `foreman start`. + +## Available Metrics + +### Standard Rails Metrics + +- `pwpush_http_requests_total` - Total HTTP requests +- `pwpush_http_request_duration_seconds` - HTTP request duration +- `pwpush_process_*` - Process metrics (CPU, memory) +- `pwpush_puma_*` - Puma server metrics +- `pwpush_active_record_*` - Database query metrics + +### Custom Password Pusher Metrics + +- `pwpush_pushes_created_total{kind, user_id}` - Total pushes created + - Labels: `kind` (text/file/url/qr), `user_id` (authenticated/anonymous) + +- `pwpush_pushes_viewed_total{push_kind, user_id}` - Total pushes viewed + - Labels: `push_kind` (text/file/url/qr), `user_id` (authenticated/anonymous) + +- `pwpush_pushes_expired_total{kind, days_lived, view_count}` - Total pushes expired + - Labels: `kind` (text/file/url/qr), `days_lived`, `view_count` + +## Configuration + +### Environment Variables + +- `PROMETHEUS_EXPORTER_HOST` - Server host (default: `localhost`) +- `PROMETHEUS_EXPORTER_PORT` - Server port (default: `9394`) + +Example: + +```bash +export PROMETHEUS_EXPORTER_HOST=0.0.0.0 +export PROMETHEUS_EXPORTER_PORT=9394 +``` + +### Disable Metrics + +Metrics are automatically disabled in test environment. To disable in other environments, remove or comment out the `prometheus` line in your Procfile. + +## Prometheus Server Configuration + +Add this scrape config to your Prometheus server's `prometheus.yml`: + +```yaml +scrape_configs: + - job_name: 'password_pusher' + static_configs: + - targets: ['localhost:9394'] + scrape_interval: 15s +``` + +For multiple instances, use service discovery or list multiple targets: + +```yaml +scrape_configs: + - job_name: 'password_pusher' + static_configs: + - targets: + - 'pwpush-1:9394' + - 'pwpush-2:9394' + - 'pwpush-3:9394' + scrape_interval: 15s +``` + +## Docker Deployment + +### Docker Compose + +The metrics endpoint needs to be exposed in your `docker-compose.yml`: + +```yaml +services: + app: + image: pglombardo/pwpush-ephemeral:latest + environment: + PROMETHEUS_EXPORTER_HOST: 0.0.0.0 + PROMETHEUS_EXPORTER_PORT: 9394 + ports: + - "3000:3000" + - "9394:9394" # Metrics endpoint +``` + +The Prometheus exporter process starts automatically via the Procfile. + +### Standalone Prometheus Container + +```yaml +services: + app: + # ... your app config ... + + prometheus: + image: prom/prometheus:latest + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml + - prometheus-data:/prometheus + ports: + - "9090:9090" + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + +volumes: + prometheus-data: +``` + +## Kubernetes Deployment + +### Expose Metrics Port + +Update your Deployment to expose the metrics port: + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: password-pusher + labels: + app: password-pusher +spec: + ports: + - name: http + port: 3000 + targetPort: 3000 + - name: metrics + port: 9394 + targetPort: 9394 + selector: + app: password-pusher +``` + +### ServiceMonitor for Prometheus Operator + +```yaml +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: password-pusher + labels: + app: password-pusher +spec: + selector: + matchLabels: + app: password-pusher + endpoints: + - port: metrics + interval: 30s + path: /metrics +``` + +### PodMonitor Alternative + +```yaml +apiVersion: monitoring.coreos.com/v1 +kind: PodMonitor +metadata: + name: password-pusher +spec: + selector: + matchLabels: + app: password-pusher + podMetricsEndpoints: + - port: metrics + interval: 30s + path: /metrics +``` + +## Example Prometheus Queries + +### Total pushes created in last 24h + +```promql +increase(pwpush_pushes_created_total[24h]) +``` + +### Push creation rate by kind + +```promql +rate(pwpush_pushes_created_total[5m]) +``` + +### Push creation rate by type + +```promql +sum by (kind) (rate(pwpush_pushes_created_total[5m])) +``` + +### Most viewed push types + +```promql +topk(5, sum by (push_kind) (pwpush_pushes_viewed_total)) +``` + +### Total active pushes created today + +```promql +sum(increase(pwpush_pushes_created_total[24h])) - sum(increase(pwpush_pushes_expired_total[24h])) +``` + +### Average HTTP request duration + +```promql +rate(pwpush_http_request_duration_seconds_sum[5m]) / rate(pwpush_http_request_duration_seconds_count[5m]) +``` + +### 95th percentile response time + +```promql +histogram_quantile(0.95, rate(pwpush_http_request_duration_seconds_bucket[5m])) +``` + +### Anonymous vs Authenticated usage + +```promql +sum by (user_id) (rate(pwpush_pushes_created_total[5m])) +``` + +## Grafana Dashboard + +### Sample Dashboard Panels + +A complete Grafana dashboard should include: + +1. **Total Pushes Created** - Counter/stat panel + + ```promql + sum(pwpush_pushes_created_total) + ``` + +2. **Push Creation Rate** - Graph panel + + ```promql + sum(rate(pwpush_pushes_created_total[5m])) + ``` + +3. **Push Views** - Graph panel + + ```promql + sum(rate(pwpush_pushes_viewed_total[5m])) + ``` + +4. **Push Types Distribution** - Pie chart + + ```promql + sum by (kind) (pwpush_pushes_created_total) + ``` + +5. **HTTP Request Duration** - Heatmap + + ```promql + rate(pwpush_http_request_duration_seconds_bucket[5m]) + ``` + +6. **Request Rate** - Graph panel + + ```promql + sum(rate(pwpush_http_requests_total[5m])) + ``` + +7. **Error Rate** - Graph panel + + ```promql + sum(rate(pwpush_http_requests_total{status=~"5.."}[5m])) + ``` + +8. **Memory Usage** - Graph panel + + ```promql + pwpush_process_resident_memory_bytes + ``` + +### Import Dashboard + +You can create a new dashboard in Grafana and import these queries, or create a dashboard JSON file for sharing. + +## Alerting Examples + +### High Error Rate + +```yaml +- alert: HighErrorRate + expr: | + sum(rate(pwpush_http_requests_total{status=~"5.."}[5m])) + / + sum(rate(pwpush_http_requests_total[5m])) + > 0.05 + for: 5m + labels: + severity: warning + annotations: + summary: "High error rate detected" + description: "Error rate is {{ $value | humanizePercentage }}" +``` + +### Slow Response Time + +```yaml +- alert: SlowResponseTime + expr: | + histogram_quantile(0.95, + rate(pwpush_http_request_duration_seconds_bucket[5m]) + ) > 2 + for: 10m + labels: + severity: warning + annotations: + summary: "Slow response times" + description: "95th percentile response time is {{ $value }}s" +``` + +### High Memory Usage + +```yaml +- alert: HighMemoryUsage + expr: pwpush_process_resident_memory_bytes > 1e9 + for: 5m + labels: + severity: warning + annotations: + summary: "High memory usage" + description: "Memory usage is {{ $value | humanize }}B" +``` + +## Troubleshooting + +### Metrics not appearing + +1. Check that the Prometheus exporter process is running: + + ```bash + ps aux | grep prometheus_server + ``` + +2. Verify the exporter is accessible: + + ```bash + curl http://localhost:9394/metrics + ``` + +3. Check logs in your foreman output for any Prometheus-related errors + +4. Ensure you started the app with `foreman start` (not `rails server` alone) + +### Port already in use + +If port 9394 is already in use, change it: + +```bash +export PROMETHEUS_EXPORTER_PORT=9395 +``` + +Then update your Prometheus scrape configuration accordingly. + +### Metrics show zero values + +- Ensure you've created some pushes and views in the application +- Metrics are counters that start at zero and increment with usage +- Check that the Rails app can connect to the exporter (check `PROMETHEUS_EXPORTER_HOST`) + +### Process not starting with foreman + +1. Verify the Procfile includes the prometheus line: + + ```bash + cat Procfile + ``` + +2. Check for Ruby/bundle errors: + + ```bash + bundle exec ruby config/prometheus_server.rb + ``` + +### Connection refused errors in logs + +The Rails app can't connect to the Prometheus exporter. Check: + +1. Exporter is running on the correct port +2. `PROMETHEUS_EXPORTER_HOST` and `PROMETHEUS_EXPORTER_PORT` match between Rails and exporter +3. Firewall rules aren't blocking localhost connections + +## Production Recommendations + +1. **Always run via Procfile** - Don't start processes manually in production +2. **Monitor the exporter process** - Ensure it stays running (use systemd, supervisord, or k8s health checks) +3. **Secure the metrics endpoint** - Use firewall rules to restrict access to Prometheus servers only +4. **Set appropriate scrape intervals** - 15-30s is usually sufficient, don't scrape too frequently +5. **Use service discovery** - In cloud/k8s environments for automatic target discovery +6. **Bind to correct interface**: + - Development: `localhost` is fine + - Production: Use `0.0.0.0` to allow external Prometheus servers +7. **Set up alerts** - Monitor for exporter process failures, high error rates, slow responses +8. **Retention** - Configure Prometheus retention based on your needs (default is 15 days) + +## Advanced Configuration + +### Custom Metrics + +To add your own custom metrics, edit [app/models/concerns/prometheus_metrics.rb](app/models/concerns/prometheus_metrics.rb): + +```ruby +# Example: Track password strength +PrometheusMetrics.track_metric("password_strength_checked", { + strength: "strong" +}) +``` + +And add the corresponding collector in [config/prometheus_server.rb](config/prometheus_server.rb). + +### Alternative Collectors + +You can add more collectors for other components: + +- Sidekiq metrics (if using Sidekiq instead of SolidQueue) +- Redis metrics +- Custom business metrics +- External service metrics + +See the [prometheus_exporter documentation](https://github.com/discourse/prometheus_exporter) for details. + +## Additional Resources + +- [prometheus_exporter gem](https://github.com/discourse/prometheus_exporter) +- [Prometheus documentation](https://prometheus.io/docs/) +- [Grafana dashboards](https://grafana.com/grafana/dashboards/) +- [PromQL cheat sheet](https://promlabs.com/promql-cheat-sheet/) +- [Prometheus best practices](https://prometheus.io/docs/practices/) diff --git a/Procfile b/Procfile index 6490d07011a..5105c7eb198 100644 --- a/Procfile +++ b/Procfile @@ -1,2 +1,3 @@ web: bundle exec bin/thrust bin/rails server worker: bundle exec rake solid_queue:start +prometheus: bundle exec ruby config/prometheus_server.rb diff --git a/Procfile.dev b/Procfile.dev index ad46d05d5e7..7aa9692a97d 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,4 +1,5 @@ web: bin/rails server -p 5100 worker: bundle exec rake solid_queue:start +prometheus: bundle exec ruby config/prometheus_server.rb css: yarn build:css --watch js: yarn build --watch diff --git a/app/models/audit_log.rb b/app/models/audit_log.rb index 3b3ccf1d7d7..a9d8e5e4ea4 100644 --- a/app/models/audit_log.rb +++ b/app/models/audit_log.rb @@ -1,12 +1,26 @@ # frozen_string_literal: true class AuditLog < ApplicationRecord + include PrometheusMetrics + enum :kind, [:creation, :view, :failed_view, :expire, :failed_passphrase], validate: true belongs_to :push belongs_to :user, optional: true + # Track views in Prometheus + after_create :track_view_metric, if: :view? + + private + + def track_view_metric + PrometheusMetrics.track_metric("push_viewed", { + push_kind: push.kind, + user_id: user_id.present? ? "authenticated" : "anonymous" + }) + end + def subject_name - user&.email || "❓" + user&.email || "anonymous" end end diff --git a/app/models/concerns/prometheus_metrics.rb b/app/models/concerns/prometheus_metrics.rb new file mode 100644 index 00000000000..43d62729eea --- /dev/null +++ b/app/models/concerns/prometheus_metrics.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +# Concern to add Prometheus metrics tracking to models +module PrometheusMetrics + extend ActiveSupport::Concern + + # Send a metric to Prometheus + def self.track_metric(action, labels = {}) + return if Rails.env.test? + return unless defined?(PrometheusExporter::Client) + + PrometheusExporter::Client.default.send_json( + type: "password_pusher", + action: action, + labels: labels + ) + rescue StandardError => e + Rails.logger.error("Prometheus metric tracking failed: #{e.message}") + end + + class_methods do + # Track push creation with callback + def track_push_created + after_create do + PrometheusMetrics.track_metric("push_created", { + kind: kind, + user_id: user_id.present? ? "authenticated" : "anonymous" + }) + end + end + + # Track push expiration with callback + def track_push_expired + after_update :track_expiration_metric, if: :saved_change_to_expired? + end + end + + private + + def track_expiration_metric + return unless expired? + + PrometheusMetrics.track_metric("push_expired", { + kind: kind, + days_lived: days_old, + view_count: view_count + }) + end +end diff --git a/app/models/push.rb b/app/models/push.rb index 77bfb339594..780cb452d57 100644 --- a/app/models/push.rb +++ b/app/models/push.rb @@ -3,8 +3,14 @@ require "addressable/uri" class Push < ApplicationRecord + include PrometheusMetrics + enum :kind, [:text, :file, :url, :qr], validate: true + # Track Prometheus metrics + track_push_created + track_push_expired + validate :check_enabled_push_kinds validates :url_token, presence: true, uniqueness: true diff --git a/config/initializers/prometheus.rb b/config/initializers/prometheus.rb new file mode 100644 index 00000000000..b07a6fc0c83 --- /dev/null +++ b/config/initializers/prometheus.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +# Prometheus metrics exporter configuration +# Documentation: https://github.com/discourse/prometheus_exporter + +unless Rails.env.test? + require "prometheus_exporter/middleware" + require "prometheus_exporter/instrumentation" + + # Start the prometheus exporter process + # This process will collect metrics and serve them on /metrics endpoint + PrometheusExporter::Metric::Base.default_prefix = "pwpush" + + # Configure the client to send metrics to the exporter process + PrometheusExporter::Client.default = PrometheusExporter::Client.new( + host: ENV.fetch("PROMETHEUS_EXPORTER_HOST", "localhost"), + port: ENV.fetch("PROMETHEUS_EXPORTER_PORT", 9394).to_i + ) + + # Use the middleware to track web requests + Rails.application.middleware.unshift PrometheusExporter::Middleware + + # Puma metrics (if using Puma) + if defined?(Puma) + PrometheusExporter::Instrumentation::Puma.start + end + + # Process metrics (CPU, memory, etc.) + PrometheusExporter::Instrumentation::Process.start(type: "web") + + # ActiveRecord metrics (if using ActiveRecord) + if defined?(ActiveRecord) + PrometheusExporter::Instrumentation::ActiveRecord.start( + custom_labels: {app: "password_pusher"}, + config_labels: [:database, :host] + ) + end + + # Delayed Job metrics (if using Delayed Job) + if defined?(Delayed::Job) + PrometheusExporter::Instrumentation::DelayedJob.start + end + + # Custom metrics collector for Password Pusher specific metrics + # This will be used to track pushes, views, etc. + class PasswordPusherMetricsCollector < PrometheusExporter::Server::TypeCollector + def initialize + @pushes_created = PrometheusExporter::Metric::Counter.new( + "pushes_created_total", + "Total number of pushes created" + ) + @pushes_viewed = PrometheusExporter::Metric::Counter.new( + "pushes_viewed_total", + "Total number of pushes viewed" + ) + @pushes_expired = PrometheusExporter::Metric::Counter.new( + "pushes_expired_total", + "Total number of pushes that have expired" + ) + end + + def type + "password_pusher" + end + + def collect(obj) + labels = obj["labels"] || {} + + case obj["action"] + when "push_created" + @pushes_created.observe(1, labels) + when "push_viewed" + @pushes_viewed.observe(1, labels) + when "push_expired" + @pushes_expired.observe(1, labels) + end + end + + def metrics + [@pushes_created, @pushes_viewed, @pushes_expired] + end + end +end diff --git a/config/prometheus_server.rb b/config/prometheus_server.rb new file mode 100644 index 00000000000..e56b5a9c1f2 --- /dev/null +++ b/config/prometheus_server.rb @@ -0,0 +1,74 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Prometheus Exporter Server +# This script starts the Prometheus metrics collection server +# Run this in a separate process alongside your Rails application +# +# Usage: +# bundle exec ruby config/prometheus_server.rb + +require "prometheus_exporter" +require "prometheus_exporter/server" +require "prometheus_exporter/instrumentation" + +# Load the custom collector +require_relative "../app/models/concerns/prometheus_metrics" + +# Custom collector for Password Pusher metrics +class PasswordPusherMetricsCollector < PrometheusExporter::Server::TypeCollector + def initialize + @pushes_created = PrometheusExporter::Metric::Counter.new( + "pwpush_pushes_created_total", + "Total number of pushes created" + ) + @pushes_viewed = PrometheusExporter::Metric::Counter.new( + "pwpush_pushes_viewed_total", + "Total number of pushes viewed" + ) + @pushes_expired = PrometheusExporter::Metric::Counter.new( + "pwpush_pushes_expired_total", + "Total number of pushes that have expired" + ) + end + + def type + "password_pusher" + end + + def collect(obj) + labels = obj["labels"] || {} + + case obj["action"] + when "push_created" + @pushes_created.observe(1, labels) + when "push_viewed" + @pushes_viewed.observe(1, labels) + when "push_expired" + @pushes_expired.observe(1, labels) + end + end + + def metrics + [@pushes_created, @pushes_viewed, @pushes_expired] + end +end + +# Configuration +port = ENV.fetch("PROMETHEUS_EXPORTER_PORT", 9394).to_i +host = ENV.fetch("PROMETHEUS_EXPORTER_HOST", "localhost") + +puts "Starting Prometheus Exporter Server on #{host}:#{port}" +puts "Metrics will be available at http://#{host}:#{port}/metrics" + +# Create and start the server +server = PrometheusExporter::Server::WebServer.new(port: port, bind: host) + +# Register the custom collector +server.collector.register_collector(PasswordPusherMetricsCollector.new) + +# Start the server +server.start + +# Keep the process running +sleep diff --git a/config/routes.rb b/config/routes.rb index 4b4df586e58..0b31b67d122 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -33,5 +33,11 @@ [200, {"Content-Type" => "text/html"}, [""]] } + # Prometheus metrics endpoint + unless Rails.env.test? + require "prometheus_exporter/server" + mount PrometheusExporter::Server::WebServer.new => "/metrics" + end + post "/csp-violation-report", to: "csp_reports#create" end From 21cf3d59b1a1970ebf9f150ad6eaecb8a2ff7a67 Mon Sep 17 00:00:00 2001 From: Cedric Grard Date: Fri, 5 Dec 2025 10:51:19 +0100 Subject: [PATCH 02/11] Change default subject name to a question mark for anonymous users --- app/models/audit_log.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/audit_log.rb b/app/models/audit_log.rb index a9d8e5e4ea4..61234359daa 100644 --- a/app/models/audit_log.rb +++ b/app/models/audit_log.rb @@ -21,6 +21,6 @@ def track_view_metric end def subject_name - user&.email || "anonymous" + user&.email || "❓" end end From 690cea1420bda0b893512c27a8f9bc90793915e5 Mon Sep 17 00:00:00 2001 From: Cedric Grard Date: Fri, 5 Dec 2025 11:01:16 +0100 Subject: [PATCH 03/11] Add Prometheus metrics for user authentication and push events --- METRICS.md | 512 ++++++++++++++++++++++ PROMETHEUS.md | 190 +------- app/models/audit_log.rb | 20 +- app/models/concerns/prometheus_metrics.rb | 20 +- app/models/user.rb | 21 + config/initializers/devise_prometheus.rb | 27 ++ config/prometheus_server.rb | 78 +++- 7 files changed, 679 insertions(+), 189 deletions(-) create mode 100644 METRICS.md create mode 100644 config/initializers/devise_prometheus.rb diff --git a/METRICS.md b/METRICS.md new file mode 100644 index 00000000000..52ef8450ddf --- /dev/null +++ b/METRICS.md @@ -0,0 +1,512 @@ +# Password Pusher Metrics Reference + +This document lists all Prometheus metrics available in Password Pusher. + +## Metrics Overview + +Password Pusher exports both **standard Rails metrics** (HTTP requests, database, server) and **custom business metrics** specific to password sharing operations. + +## Custom Business Metrics + +### Push Lifecycle Metrics + +#### `pwpush_pushes_created_total` +Total number of pushes created. + +**Labels:** +- `kind` - Type of push: `text`, `file`, `url`, or `qr` +- `user_type` - Creator type: `authenticated` or `anonymous` +- `has_passphrase` - Whether push is passphrase-protected: `yes` or `no` +- `deletable_by_viewer` - Whether viewers can delete: `yes` or `no` +- `retrieval_step` - Whether anti-bot retrieval step is enabled: `yes` or `no` +- `file_count` - Number of files attached (only for file pushes) +- `total_file_size` - Total size in bytes of all files (only for file pushes) + +**Example queries:** +```promql +# Total pushes created +sum(pwpush_pushes_created_total) + +# Pushes by type +sum by (kind) (pwpush_pushes_created_total) + +# Passphrase-protected pushes +sum(pwpush_pushes_created_total{has_passphrase="yes"}) + +# Anonymous vs authenticated ratio +sum by (user_type) (pwpush_pushes_created_total) +``` + +#### `pwpush_pushes_viewed_total` +Total number of successful push views. + +**Labels:** +- `push_kind` - Type of push: `text`, `file`, `url`, or `qr` +- `user_type` - Viewer type: `authenticated` or `anonymous` +- `had_passphrase` - Whether push was passphrase-protected: `yes` or `no` + +**Example queries:** +```promql +# View rate per minute +rate(pwpush_pushes_viewed_total[5m]) + +# Views by push type +sum by (push_kind) (pwpush_pushes_viewed_total) + +# Passphrase-protected views +sum(pwpush_pushes_viewed_total{had_passphrase="yes"}) +``` + +#### `pwpush_pushes_expired_total` +Total number of pushes that have expired. + +**Labels:** +- `kind` - Type of push: `text`, `file`, `url`, or `qr` +- `days_lived` - Number of days the push existed before expiration +- `view_count` - Number of times the push was viewed before expiration +- `had_passphrase` - Whether push was passphrase-protected: `yes` or `no` + +**Example queries:** +```promql +# Expiration rate +rate(pwpush_pushes_expired_total[1h]) + +# Average push lifetime +avg(pwpush_pushes_expired_total{days_lived!=""}) + +# Pushes that expired without being viewed +sum(pwpush_pushes_expired_total{view_count="0"}) +``` + +### Security Metrics + +#### `pwpush_pushes_failed_view_total` +Total number of failed view attempts (trying to access expired/deleted pushes). + +**Labels:** +- `push_kind` - Type of push: `text`, `file`, `url`, or `qr` +- `user_type` - Viewer type: `authenticated` or `anonymous` +- `reason` - Failure reason: `expired_or_deleted` + +**Example queries:** +```promql +# Failed view rate (potential security probing) +rate(pwpush_pushes_failed_view_total[5m]) + +# Failed views by type +sum by (push_kind) (pwpush_pushes_failed_view_total) +``` + +**Alert example:** +```yaml +- alert: HighFailedViewRate + expr: rate(pwpush_pushes_failed_view_total[5m]) > 10 + for: 5m + annotations: + summary: "High rate of failed view attempts" +``` + +#### `pwpush_pushes_failed_passphrase_total` +Total number of failed passphrase attempts. + +**Labels:** +- `push_kind` - Type of push: `text`, `file`, `url`, or `qr` +- `user_type` - Viewer type: `authenticated` or `anonymous` + +**Example queries:** +```promql +# Failed passphrase rate (brute force detection) +rate(pwpush_pushes_failed_passphrase_total[5m]) + +# Failed attempts by user type +sum by (user_type) (pwpush_pushes_failed_passphrase_total) +``` + +**Alert example:** +```yaml +- alert: PassphraseBruteForce + expr: rate(pwpush_pushes_failed_passphrase_total[1m]) > 5 + for: 2m + annotations: + summary: "Potential passphrase brute force attack" +``` + +### File Upload Metrics + +#### `pwpush_file_uploads_total` +Total number of files uploaded. + +**Labels:** +- `kind` - Type of push: `file` +- `user_type` - Creator type: `authenticated` or `anonymous` + +**Example queries:** +```promql +# File upload rate +rate(pwpush_file_uploads_total[5m]) + +# Total files uploaded today +increase(pwpush_file_uploads_total[24h]) +``` + +#### `pwpush_file_upload_bytes_total` +Total bytes uploaded in files. + +**Labels:** +- `kind` - Type of push: `file` +- `user_type` - Creator type: `authenticated` or `anonymous` + +**Example queries:** +```promql +# Upload bandwidth (bytes per second) +rate(pwpush_file_upload_bytes_total[5m]) + +# Total storage used today (in GB) +increase(pwpush_file_upload_bytes_total[24h]) / 1024 / 1024 / 1024 + +# Average file size +rate(pwpush_file_upload_bytes_total[5m]) / rate(pwpush_file_uploads_total[5m]) +``` + +### User Authentication Metrics + +#### `pwpush_user_signup_total` +Total number of user signups. + +**Labels:** +- `locale` - User's preferred language or `default` + +**Example queries:** +```promql +# Signup rate +rate(pwpush_user_signup_total[1h]) + +# Signups by language +sum by (locale) (pwpush_user_signup_total) +``` + +#### `pwpush_user_login_success_total` +Total number of successful logins. + +**Labels:** +- `user_type` - User role: `admin` or `user` + +**Example queries:** +```promql +# Login rate +rate(pwpush_user_login_success_total[5m]) + +# Admin vs regular user logins +sum by (user_type) (pwpush_user_login_success_total) +``` + +#### `pwpush_user_login_failed_total` +Total number of failed login attempts. + +**Labels:** +- `reason` - Failure reason: `invalid_credentials`, etc. + +**Example queries:** +```promql +# Failed login rate (security monitoring) +rate(pwpush_user_login_failed_total[5m]) + +# Failed logins by reason +sum by (reason) (pwpush_user_login_failed_total) + +# Login success rate +rate(pwpush_user_login_success_total[5m]) / +(rate(pwpush_user_login_success_total[5m]) + rate(pwpush_user_login_failed_total[5m])) +``` + +**Alert example:** +```yaml +- alert: HighFailedLoginRate + expr: rate(pwpush_user_login_failed_total[5m]) > 5 + for: 5m + annotations: + summary: "High rate of failed login attempts" +``` + +#### `pwpush_user_logout_total` +Total number of user logouts. + +**Labels:** +- `user_type` - User role: `admin` or `user` + +**Example queries:** +```promql +# Logout rate +rate(pwpush_user_logout_total[5m]) +``` + +#### `pwpush_user_locked_total` +Total number of users locked due to too many failed login attempts. + +**Labels:** +- `reason` - Lock reason: `too_many_failed_attempts` + +**Example queries:** +```promql +# Account lockouts (security incident indicator) +increase(pwpush_user_locked_total[24h]) +``` + +**Alert example:** +```yaml +- alert: AccountLockouts + expr: increase(pwpush_user_locked_total[1h]) > 3 + for: 5m + annotations: + summary: "Multiple account lockouts detected" +``` + +## Standard Rails Metrics + +These metrics are automatically collected by `prometheus_exporter`: + +### HTTP Metrics + +- `pwpush_http_requests_total{method, status, path}` - Total HTTP requests +- `pwpush_http_request_duration_seconds{method, status, path}` - Request duration histogram + +### Database Metrics + +- `pwpush_active_record_connection_pool_size` - Connection pool size +- `pwpush_active_record_connection_pool_connections` - Active connections +- `pwpush_active_record_connection_pool_busy` - Busy connections +- `pwpush_active_record_connection_pool_dead` - Dead connections + +### Process Metrics + +- `pwpush_process_resident_memory_bytes` - Memory usage +- `pwpush_process_cpu_seconds_total` - CPU time + +### Puma Metrics + +- `pwpush_puma_workers` - Number of Puma workers +- `pwpush_puma_booted_workers` - Number of booted workers +- `pwpush_puma_running_threads` - Number of running threads +- `pwpush_puma_request_backlog` - Request queue depth + +## Key Dashboards + +### Security Dashboard + +```promql +# Failed passphrase attempts rate +rate(pwpush_pushes_failed_passphrase_total[5m]) + +# Failed login attempts rate +rate(pwpush_user_login_failed_total[5m]) + +# Failed view attempts rate +rate(pwpush_pushes_failed_view_total[5m]) + +# Account lockouts in last hour +increase(pwpush_user_locked_total[1h]) +``` + +### Business Metrics Dashboard + +```promql +# Total pushes created today +increase(pwpush_pushes_created_total[24h]) + +# Push creation rate +rate(pwpush_pushes_created_total[5m]) + +# Pushes by type +sum by (kind) (pwpush_pushes_created_total) + +# Anonymous vs authenticated usage +sum by (user_type) (pwpush_pushes_created_total) + +# Passphrase adoption rate +sum(pwpush_pushes_created_total{has_passphrase="yes"}) / sum(pwpush_pushes_created_total) +``` + +### File Upload Dashboard + +```promql +# Files uploaded today +increase(pwpush_file_uploads_total[24h]) + +# Total bytes uploaded today (GB) +increase(pwpush_file_upload_bytes_total[24h]) / 1024 / 1024 / 1024 + +# Upload bandwidth +rate(pwpush_file_upload_bytes_total[5m]) + +# Average file size +rate(pwpush_file_upload_bytes_total[5m]) / rate(pwpush_file_uploads_total[5m]) +``` + +### User Engagement Dashboard + +```promql +# New signups today +increase(pwpush_user_signup_total[24h]) + +# Login success rate +rate(pwpush_user_login_success_total[5m]) / +(rate(pwpush_user_login_success_total[5m]) + rate(pwpush_user_login_failed_total[5m])) + +# Active sessions (logins - logouts) +increase(pwpush_user_login_success_total[1h]) - increase(pwpush_user_logout_total[1h]) +``` + +### Performance Dashboard + +```promql +# 95th percentile response time +histogram_quantile(0.95, rate(pwpush_http_request_duration_seconds_bucket[5m])) + +# Request rate +rate(pwpush_http_requests_total[5m]) + +# Error rate +sum(rate(pwpush_http_requests_total{status=~"5.."}[5m])) + +# Memory usage +pwpush_process_resident_memory_bytes + +# Database connection pool usage +pwpush_active_record_connection_pool_busy / pwpush_active_record_connection_pool_size +``` + +## Recommended Alerts + +### Security Alerts + +```yaml +groups: + - name: security + rules: + - alert: HighFailedPassphraseRate + expr: rate(pwpush_pushes_failed_passphrase_total[1m]) > 5 + for: 2m + labels: + severity: warning + annotations: + summary: "Potential passphrase brute force attack" + + - alert: HighFailedLoginRate + expr: rate(pwpush_user_login_failed_total[5m]) > 10 + for: 5m + labels: + severity: warning + annotations: + summary: "High rate of failed login attempts" + + - alert: MultipleAccountLockouts + expr: increase(pwpush_user_locked_total[1h]) > 3 + for: 5m + labels: + severity: critical + annotations: + summary: "Multiple account lockouts detected" +``` + +### Performance Alerts + +```yaml +groups: + - name: performance + rules: + - alert: SlowResponseTime + expr: histogram_quantile(0.95, rate(pwpush_http_request_duration_seconds_bucket[5m])) > 2 + for: 10m + labels: + severity: warning + annotations: + summary: "95th percentile response time above 2s" + + - alert: HighErrorRate + expr: | + sum(rate(pwpush_http_requests_total{status=~"5.."}[5m])) / + sum(rate(pwpush_http_requests_total[5m])) > 0.05 + for: 5m + labels: + severity: critical + annotations: + summary: "Error rate above 5%" + + - alert: HighMemoryUsage + expr: pwpush_process_resident_memory_bytes > 1e9 + for: 5m + labels: + severity: warning + annotations: + summary: "Memory usage above 1GB" +``` + +### Business Alerts + +```yaml +groups: + - name: business + rules: + - alert: NoActivityDetected + expr: rate(pwpush_pushes_created_total[1h]) == 0 + for: 2h + labels: + severity: info + annotations: + summary: "No push creation activity in last 2 hours" + + - alert: HighStorageGrowth + expr: rate(pwpush_file_upload_bytes_total[1h]) > 1e9 + for: 30m + labels: + severity: warning + annotations: + summary: "File upload rate exceeding 1GB/hour" +``` + +## Integration Examples + +### Grafana Variable Queries + +```promql +# Push types +label_values(pwpush_pushes_created_total, kind) + +# User types +label_values(pwpush_pushes_created_total, user_type) + +# Locales +label_values(pwpush_user_signup_total, locale) +``` + +### Recording Rules + +```yaml +groups: + - name: password_pusher_rules + interval: 1m + rules: + # Push success rate (created vs viewed) + - record: pwpush:push_view_rate:ratio + expr: | + sum(rate(pwpush_pushes_viewed_total[5m])) / + sum(rate(pwpush_pushes_created_total[5m])) + + # Login success rate + - record: pwpush:login_success_rate:ratio + expr: | + sum(rate(pwpush_user_login_success_total[5m])) / + (sum(rate(pwpush_user_login_success_total[5m])) + sum(rate(pwpush_user_login_failed_total[5m]))) + + # Average file size + - record: pwpush:file_upload:avg_bytes + expr: | + rate(pwpush_file_upload_bytes_total[5m]) / + rate(pwpush_file_uploads_total[5m]) +``` + +## See Also + +- [PROMETHEUS.md](PROMETHEUS.md) - Full Prometheus setup documentation +- [Prometheus documentation](https://prometheus.io/docs/) +- [PromQL cheat sheet](https://promlabs.com/promql-cheat-sheet/) diff --git a/PROMETHEUS.md b/PROMETHEUS.md index ae5e8b76a3b..2fdeda841a9 100644 --- a/PROMETHEUS.md +++ b/PROMETHEUS.md @@ -1,4 +1,4 @@ -# Prometheus Metrics +# Prometheus Metrics Setup Password Pusher includes built-in Prometheus metrics export for monitoring and observability. @@ -28,6 +28,8 @@ The Prometheus exporter starts automatically as part of the application stack. Metrics are available at: **`http://localhost:9394/metrics`** +For detailed information about available metrics, see [METRICS.md](METRICS.md). + ## Architecture The Prometheus integration uses `prometheus_exporter` with two components: @@ -37,27 +39,6 @@ The Prometheus integration uses `prometheus_exporter` with two components: Both components start automatically when you run `foreman start`. -## Available Metrics - -### Standard Rails Metrics - -- `pwpush_http_requests_total` - Total HTTP requests -- `pwpush_http_request_duration_seconds` - HTTP request duration -- `pwpush_process_*` - Process metrics (CPU, memory) -- `pwpush_puma_*` - Puma server metrics -- `pwpush_active_record_*` - Database query metrics - -### Custom Password Pusher Metrics - -- `pwpush_pushes_created_total{kind, user_id}` - Total pushes created - - Labels: `kind` (text/file/url/qr), `user_id` (authenticated/anonymous) - -- `pwpush_pushes_viewed_total{push_kind, user_id}` - Total pushes viewed - - Labels: `push_kind` (text/file/url/qr), `user_id` (authenticated/anonymous) - -- `pwpush_pushes_expired_total{kind, days_lived, view_count}` - Total pushes expired - - Labels: `kind` (text/file/url/qr), `days_lived`, `view_count` - ## Configuration ### Environment Variables @@ -204,162 +185,6 @@ spec: path: /metrics ``` -## Example Prometheus Queries - -### Total pushes created in last 24h - -```promql -increase(pwpush_pushes_created_total[24h]) -``` - -### Push creation rate by kind - -```promql -rate(pwpush_pushes_created_total[5m]) -``` - -### Push creation rate by type - -```promql -sum by (kind) (rate(pwpush_pushes_created_total[5m])) -``` - -### Most viewed push types - -```promql -topk(5, sum by (push_kind) (pwpush_pushes_viewed_total)) -``` - -### Total active pushes created today - -```promql -sum(increase(pwpush_pushes_created_total[24h])) - sum(increase(pwpush_pushes_expired_total[24h])) -``` - -### Average HTTP request duration - -```promql -rate(pwpush_http_request_duration_seconds_sum[5m]) / rate(pwpush_http_request_duration_seconds_count[5m]) -``` - -### 95th percentile response time - -```promql -histogram_quantile(0.95, rate(pwpush_http_request_duration_seconds_bucket[5m])) -``` - -### Anonymous vs Authenticated usage - -```promql -sum by (user_id) (rate(pwpush_pushes_created_total[5m])) -``` - -## Grafana Dashboard - -### Sample Dashboard Panels - -A complete Grafana dashboard should include: - -1. **Total Pushes Created** - Counter/stat panel - - ```promql - sum(pwpush_pushes_created_total) - ``` - -2. **Push Creation Rate** - Graph panel - - ```promql - sum(rate(pwpush_pushes_created_total[5m])) - ``` - -3. **Push Views** - Graph panel - - ```promql - sum(rate(pwpush_pushes_viewed_total[5m])) - ``` - -4. **Push Types Distribution** - Pie chart - - ```promql - sum by (kind) (pwpush_pushes_created_total) - ``` - -5. **HTTP Request Duration** - Heatmap - - ```promql - rate(pwpush_http_request_duration_seconds_bucket[5m]) - ``` - -6. **Request Rate** - Graph panel - - ```promql - sum(rate(pwpush_http_requests_total[5m])) - ``` - -7. **Error Rate** - Graph panel - - ```promql - sum(rate(pwpush_http_requests_total{status=~"5.."}[5m])) - ``` - -8. **Memory Usage** - Graph panel - - ```promql - pwpush_process_resident_memory_bytes - ``` - -### Import Dashboard - -You can create a new dashboard in Grafana and import these queries, or create a dashboard JSON file for sharing. - -## Alerting Examples - -### High Error Rate - -```yaml -- alert: HighErrorRate - expr: | - sum(rate(pwpush_http_requests_total{status=~"5.."}[5m])) - / - sum(rate(pwpush_http_requests_total[5m])) - > 0.05 - for: 5m - labels: - severity: warning - annotations: - summary: "High error rate detected" - description: "Error rate is {{ $value | humanizePercentage }}" -``` - -### Slow Response Time - -```yaml -- alert: SlowResponseTime - expr: | - histogram_quantile(0.95, - rate(pwpush_http_request_duration_seconds_bucket[5m]) - ) > 2 - for: 10m - labels: - severity: warning - annotations: - summary: "Slow response times" - description: "95th percentile response time is {{ $value }}s" -``` - -### High Memory Usage - -```yaml -- alert: HighMemoryUsage - expr: pwpush_process_resident_memory_bytes > 1e9 - for: 5m - labels: - severity: warning - annotations: - summary: "High memory usage" - description: "Memory usage is {{ $value | humanize }}B" -``` - ## Troubleshooting ### Metrics not appearing @@ -438,9 +263,9 @@ The Rails app can't connect to the Prometheus exporter. Check: To add your own custom metrics, edit [app/models/concerns/prometheus_metrics.rb](app/models/concerns/prometheus_metrics.rb): ```ruby -# Example: Track password strength -PrometheusMetrics.track_metric("password_strength_checked", { - strength: "strong" +# Example: Track custom events +PrometheusMetrics.track_metric("custom_event", { + category: "example" }) ``` @@ -457,8 +282,9 @@ You can add more collectors for other components: See the [prometheus_exporter documentation](https://github.com/discourse/prometheus_exporter) for details. -## Additional Resources +## Documentation +- **[METRICS.md](METRICS.md)** - Complete metrics reference with all available metrics, labels, example queries, dashboards, and alerts - [prometheus_exporter gem](https://github.com/discourse/prometheus_exporter) - [Prometheus documentation](https://prometheus.io/docs/) - [Grafana dashboards](https://grafana.com/grafana/dashboards/) diff --git a/app/models/audit_log.rb b/app/models/audit_log.rb index 61234359daa..5359a1546f7 100644 --- a/app/models/audit_log.rb +++ b/app/models/audit_log.rb @@ -10,13 +10,31 @@ class AuditLog < ApplicationRecord # Track views in Prometheus after_create :track_view_metric, if: :view? + after_create :track_failed_view_metric, if: :failed_view? + after_create :track_failed_passphrase_metric, if: :failed_passphrase? private def track_view_metric PrometheusMetrics.track_metric("push_viewed", { push_kind: push.kind, - user_id: user_id.present? ? "authenticated" : "anonymous" + user_type: user_id.present? ? "authenticated" : "anonymous", + had_passphrase: push.passphrase.present? ? "yes" : "no" + }) + end + + def track_failed_view_metric + PrometheusMetrics.track_metric("push_failed_view", { + push_kind: push.kind, + user_type: user_id.present? ? "authenticated" : "anonymous", + reason: "expired_or_deleted" + }) + end + + def track_failed_passphrase_metric + PrometheusMetrics.track_metric("push_failed_passphrase", { + push_kind: push.kind, + user_type: user_id.present? ? "authenticated" : "anonymous" }) end diff --git a/app/models/concerns/prometheus_metrics.rb b/app/models/concerns/prometheus_metrics.rb index 43d62729eea..cb23f508884 100644 --- a/app/models/concerns/prometheus_metrics.rb +++ b/app/models/concerns/prometheus_metrics.rb @@ -22,10 +22,21 @@ def self.track_metric(action, labels = {}) # Track push creation with callback def track_push_created after_create do - PrometheusMetrics.track_metric("push_created", { + labels = { kind: kind, - user_id: user_id.present? ? "authenticated" : "anonymous" - }) + user_type: user_id.present? ? "authenticated" : "anonymous", + has_passphrase: respond_to?(:passphrase) && passphrase.present? ? "yes" : "no", + deletable_by_viewer: deletable_by_viewer ? "yes" : "no", + retrieval_step: retrieval_step ? "yes" : "no" + } + + # Add file-specific metrics + if respond_to?(:files) && files.attached? + labels[:file_count] = files.count + labels[:total_file_size] = files.sum(&:byte_size) + end + + PrometheusMetrics.track_metric("push_created", labels) end end @@ -43,7 +54,8 @@ def track_expiration_metric PrometheusMetrics.track_metric("push_expired", { kind: kind, days_lived: days_old, - view_count: view_count + view_count: view_count, + had_passphrase: passphrase_ciphertext.present? ? "yes" : "no" }) end end diff --git a/app/models/user.rb b/app/models/user.rb index 04b87d85d01..11dcde81878 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -2,6 +2,7 @@ class User < ApplicationRecord include Pwpush::TokenAuthentication + include PrometheusMetrics # Include default devise modules. Others available are: # :timeoutable and :omniauthable @@ -13,7 +14,27 @@ class User < ApplicationRecord attr_readonly :admin + # Track authentication events + after_create :track_user_signup + after_update :track_user_locked, if: :saved_change_to_locked_at? + def admin? admin end + + private + + def track_user_signup + PrometheusMetrics.track_metric("user_signup", { + locale: preferred_language || "default" + }) + end + + def track_user_locked + return unless locked_at.present? + + PrometheusMetrics.track_metric("user_locked", { + reason: "too_many_failed_attempts" + }) + end end diff --git a/config/initializers/devise_prometheus.rb b/config/initializers/devise_prometheus.rb new file mode 100644 index 00000000000..0b5afd9663b --- /dev/null +++ b/config/initializers/devise_prometheus.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# Track Devise authentication events in Prometheus +unless Rails.env.test? + require_relative "../../app/models/concerns/prometheus_metrics" + + # Track successful logins + Warden::Manager.after_authentication do |user, auth, opts| + PrometheusMetrics.track_metric("user_login_success", { + user_type: user.admin? ? "admin" : "user" + }) + end + + # Track failed login attempts + Warden::Manager.before_failure do |env, opts| + PrometheusMetrics.track_metric("user_login_failed", { + reason: opts[:message] || "invalid_credentials" + }) + end + + # Track logout events + Warden::Manager.before_logout do |user, auth, opts| + PrometheusMetrics.track_metric("user_logout", { + user_type: user.admin? ? "admin" : "user" + }) + end +end diff --git a/config/prometheus_server.rb b/config/prometheus_server.rb index e56b5a9c1f2..2ad17a27530 100644 --- a/config/prometheus_server.rb +++ b/config/prometheus_server.rb @@ -18,18 +18,59 @@ # Custom collector for Password Pusher metrics class PasswordPusherMetricsCollector < PrometheusExporter::Server::TypeCollector def initialize + # Push metrics @pushes_created = PrometheusExporter::Metric::Counter.new( "pwpush_pushes_created_total", "Total number of pushes created" ) @pushes_viewed = PrometheusExporter::Metric::Counter.new( "pwpush_pushes_viewed_total", - "Total number of pushes viewed" + "Total number of pushes viewed successfully" ) @pushes_expired = PrometheusExporter::Metric::Counter.new( "pwpush_pushes_expired_total", "Total number of pushes that have expired" ) + @pushes_failed_view = PrometheusExporter::Metric::Counter.new( + "pwpush_pushes_failed_view_total", + "Total number of failed view attempts (expired/deleted)" + ) + @pushes_failed_passphrase = PrometheusExporter::Metric::Counter.new( + "pwpush_pushes_failed_passphrase_total", + "Total number of failed passphrase attempts" + ) + + # File upload metrics + @file_uploads_total = PrometheusExporter::Metric::Counter.new( + "pwpush_file_uploads_total", + "Total number of files uploaded" + ) + @file_upload_bytes = PrometheusExporter::Metric::Counter.new( + "pwpush_file_upload_bytes_total", + "Total bytes uploaded in files" + ) + + # User authentication metrics + @user_signup = PrometheusExporter::Metric::Counter.new( + "pwpush_user_signup_total", + "Total number of user signups" + ) + @user_login_success = PrometheusExporter::Metric::Counter.new( + "pwpush_user_login_success_total", + "Total number of successful logins" + ) + @user_login_failed = PrometheusExporter::Metric::Counter.new( + "pwpush_user_login_failed_total", + "Total number of failed login attempts" + ) + @user_logout = PrometheusExporter::Metric::Counter.new( + "pwpush_user_logout_total", + "Total number of user logouts" + ) + @user_locked = PrometheusExporter::Metric::Counter.new( + "pwpush_user_locked_total", + "Total number of users locked due to failed login attempts" + ) end def type @@ -42,15 +83,48 @@ def collect(obj) case obj["action"] when "push_created" @pushes_created.observe(1, labels) + # Track file upload metrics if present + if labels["file_count"] + file_labels = {kind: labels["kind"], user_type: labels["user_type"]} + @file_uploads_total.observe(labels["file_count"].to_i, file_labels) + @file_upload_bytes.observe(labels["total_file_size"].to_i, file_labels) + end when "push_viewed" @pushes_viewed.observe(1, labels) when "push_expired" @pushes_expired.observe(1, labels) + when "push_failed_view" + @pushes_failed_view.observe(1, labels) + when "push_failed_passphrase" + @pushes_failed_passphrase.observe(1, labels) + when "user_signup" + @user_signup.observe(1, labels) + when "user_login_success" + @user_login_success.observe(1, labels) + when "user_login_failed" + @user_login_failed.observe(1, labels) + when "user_logout" + @user_logout.observe(1, labels) + when "user_locked" + @user_locked.observe(1, labels) end end def metrics - [@pushes_created, @pushes_viewed, @pushes_expired] + [ + @pushes_created, + @pushes_viewed, + @pushes_expired, + @pushes_failed_view, + @pushes_failed_passphrase, + @file_uploads_total, + @file_upload_bytes, + @user_signup, + @user_login_success, + @user_login_failed, + @user_logout, + @user_locked + ] end end From f2e6cd35939c11d99de13a91d2184a8f1508bb6d Mon Sep 17 00:00:00 2001 From: Cedric Grard Date: Fri, 5 Dec 2025 11:02:52 +0100 Subject: [PATCH 04/11] Enhance metrics documentation with additional labels and examples for push lifecycle, security, file upload, and user authentication metrics --- METRICS.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/METRICS.md b/METRICS.md index 52ef8450ddf..61ad8c6437b 100644 --- a/METRICS.md +++ b/METRICS.md @@ -11,9 +11,11 @@ Password Pusher exports both **standard Rails metrics** (HTTP requests, database ### Push Lifecycle Metrics #### `pwpush_pushes_created_total` + Total number of pushes created. **Labels:** + - `kind` - Type of push: `text`, `file`, `url`, or `qr` - `user_type` - Creator type: `authenticated` or `anonymous` - `has_passphrase` - Whether push is passphrase-protected: `yes` or `no` @@ -23,6 +25,7 @@ Total number of pushes created. - `total_file_size` - Total size in bytes of all files (only for file pushes) **Example queries:** + ```promql # Total pushes created sum(pwpush_pushes_created_total) @@ -38,14 +41,17 @@ sum by (user_type) (pwpush_pushes_created_total) ``` #### `pwpush_pushes_viewed_total` + Total number of successful push views. **Labels:** + - `push_kind` - Type of push: `text`, `file`, `url`, or `qr` - `user_type` - Viewer type: `authenticated` or `anonymous` - `had_passphrase` - Whether push was passphrase-protected: `yes` or `no` **Example queries:** + ```promql # View rate per minute rate(pwpush_pushes_viewed_total[5m]) @@ -58,15 +64,18 @@ sum(pwpush_pushes_viewed_total{had_passphrase="yes"}) ``` #### `pwpush_pushes_expired_total` + Total number of pushes that have expired. **Labels:** + - `kind` - Type of push: `text`, `file`, `url`, or `qr` - `days_lived` - Number of days the push existed before expiration - `view_count` - Number of times the push was viewed before expiration - `had_passphrase` - Whether push was passphrase-protected: `yes` or `no` **Example queries:** + ```promql # Expiration rate rate(pwpush_pushes_expired_total[1h]) @@ -81,14 +90,17 @@ sum(pwpush_pushes_expired_total{view_count="0"}) ### Security Metrics #### `pwpush_pushes_failed_view_total` + Total number of failed view attempts (trying to access expired/deleted pushes). **Labels:** + - `push_kind` - Type of push: `text`, `file`, `url`, or `qr` - `user_type` - Viewer type: `authenticated` or `anonymous` - `reason` - Failure reason: `expired_or_deleted` **Example queries:** + ```promql # Failed view rate (potential security probing) rate(pwpush_pushes_failed_view_total[5m]) @@ -98,6 +110,7 @@ sum by (push_kind) (pwpush_pushes_failed_view_total) ``` **Alert example:** + ```yaml - alert: HighFailedViewRate expr: rate(pwpush_pushes_failed_view_total[5m]) > 10 @@ -107,13 +120,16 @@ sum by (push_kind) (pwpush_pushes_failed_view_total) ``` #### `pwpush_pushes_failed_passphrase_total` + Total number of failed passphrase attempts. **Labels:** + - `push_kind` - Type of push: `text`, `file`, `url`, or `qr` - `user_type` - Viewer type: `authenticated` or `anonymous` **Example queries:** + ```promql # Failed passphrase rate (brute force detection) rate(pwpush_pushes_failed_passphrase_total[5m]) @@ -123,6 +139,7 @@ sum by (user_type) (pwpush_pushes_failed_passphrase_total) ``` **Alert example:** + ```yaml - alert: PassphraseBruteForce expr: rate(pwpush_pushes_failed_passphrase_total[1m]) > 5 @@ -134,13 +151,16 @@ sum by (user_type) (pwpush_pushes_failed_passphrase_total) ### File Upload Metrics #### `pwpush_file_uploads_total` + Total number of files uploaded. **Labels:** + - `kind` - Type of push: `file` - `user_type` - Creator type: `authenticated` or `anonymous` **Example queries:** + ```promql # File upload rate rate(pwpush_file_uploads_total[5m]) @@ -150,13 +170,16 @@ increase(pwpush_file_uploads_total[24h]) ``` #### `pwpush_file_upload_bytes_total` + Total bytes uploaded in files. **Labels:** + - `kind` - Type of push: `file` - `user_type` - Creator type: `authenticated` or `anonymous` **Example queries:** + ```promql # Upload bandwidth (bytes per second) rate(pwpush_file_upload_bytes_total[5m]) @@ -171,12 +194,15 @@ rate(pwpush_file_upload_bytes_total[5m]) / rate(pwpush_file_uploads_total[5m]) ### User Authentication Metrics #### `pwpush_user_signup_total` + Total number of user signups. **Labels:** + - `locale` - User's preferred language or `default` **Example queries:** + ```promql # Signup rate rate(pwpush_user_signup_total[1h]) @@ -186,12 +212,15 @@ sum by (locale) (pwpush_user_signup_total) ``` #### `pwpush_user_login_success_total` + Total number of successful logins. **Labels:** + - `user_type` - User role: `admin` or `user` **Example queries:** + ```promql # Login rate rate(pwpush_user_login_success_total[5m]) @@ -201,12 +230,15 @@ sum by (user_type) (pwpush_user_login_success_total) ``` #### `pwpush_user_login_failed_total` + Total number of failed login attempts. **Labels:** + - `reason` - Failure reason: `invalid_credentials`, etc. **Example queries:** + ```promql # Failed login rate (security monitoring) rate(pwpush_user_login_failed_total[5m]) @@ -220,6 +252,7 @@ rate(pwpush_user_login_success_total[5m]) / ``` **Alert example:** + ```yaml - alert: HighFailedLoginRate expr: rate(pwpush_user_login_failed_total[5m]) > 5 @@ -229,30 +262,37 @@ rate(pwpush_user_login_success_total[5m]) / ``` #### `pwpush_user_logout_total` + Total number of user logouts. **Labels:** + - `user_type` - User role: `admin` or `user` **Example queries:** + ```promql # Logout rate rate(pwpush_user_logout_total[5m]) ``` #### `pwpush_user_locked_total` + Total number of users locked due to too many failed login attempts. **Labels:** + - `reason` - Lock reason: `too_many_failed_attempts` **Example queries:** + ```promql # Account lockouts (security incident indicator) increase(pwpush_user_locked_total[24h]) ``` **Alert example:** + ```yaml - alert: AccountLockouts expr: increase(pwpush_user_locked_total[1h]) > 3 From 785b34bf8b8cbedd54e00d0fda7a29e2679cc147 Mon Sep 17 00:00:00 2001 From: Cedric Grard Date: Fri, 5 Dec 2025 11:33:36 +0100 Subject: [PATCH 05/11] Add prometheus_exporter gem for enhanced monitoring capabilities --- Gemfile.lock | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Gemfile.lock b/Gemfile.lock index 89d93788c20..932fed8ac24 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -505,6 +505,8 @@ GEM forwardable singleton prism (1.6.0) + prometheus_exporter (2.3.1) + webrick propshaft (1.3.1) actionpack (>= 7.0.0) activesupport (>= 7.0.0) @@ -753,6 +755,7 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) + webrick (1.9.2) websocket (1.2.11) websocket-driver (0.8.0) base64 @@ -834,6 +837,7 @@ DEPENDENCIES overcommit overmind (~> 2.5) pg + prometheus_exporter propshaft pry-rails puma From 3c2177196d33a518efbeadb9c328652d87208381 Mon Sep 17 00:00:00 2001 From: Cedric Grard Date: Fri, 5 Dec 2025 11:49:56 +0100 Subject: [PATCH 06/11] Refactor Prometheus metrics initialization and remove unused custom collector --- config/initializers/prometheus.rb | 64 ++++++------------------------- config/prometheus_server.rb | 3 -- 2 files changed, 11 insertions(+), 56 deletions(-) diff --git a/config/initializers/prometheus.rb b/config/initializers/prometheus.rb index b07a6fc0c83..c9b9e8d2908 100644 --- a/config/initializers/prometheus.rb +++ b/config/initializers/prometheus.rb @@ -7,10 +7,6 @@ require "prometheus_exporter/middleware" require "prometheus_exporter/instrumentation" - # Start the prometheus exporter process - # This process will collect metrics and serve them on /metrics endpoint - PrometheusExporter::Metric::Base.default_prefix = "pwpush" - # Configure the client to send metrics to the exporter process PrometheusExporter::Client.default = PrometheusExporter::Client.new( host: ENV.fetch("PROMETHEUS_EXPORTER_HOST", "localhost"), @@ -20,13 +16,20 @@ # Use the middleware to track web requests Rails.application.middleware.unshift PrometheusExporter::Middleware - # Puma metrics (if using Puma) - if defined?(Puma) - PrometheusExporter::Instrumentation::Puma.start + # Puma metrics (only in production with clustered mode) + # Disabled in development as single-mode Puma doesn't support stats + if defined?(Puma) && File.basename($PROGRAM_NAME) != "rake" && Rails.env.production? + Rails.application.config.after_initialize do + PrometheusExporter::Instrumentation::Puma.start + rescue StandardError => e + Rails.logger.warn("Puma metrics not available: #{e.message}") + end end # Process metrics (CPU, memory, etc.) - PrometheusExporter::Instrumentation::Process.start(type: "web") + # Use "web" type for web processes, "sidekiq" for workers + process_type = File.basename($PROGRAM_NAME) == "rake" ? "sidekiq" : "web" + PrometheusExporter::Instrumentation::Process.start(type: process_type) # ActiveRecord metrics (if using ActiveRecord) if defined?(ActiveRecord) @@ -35,49 +38,4 @@ config_labels: [:database, :host] ) end - - # Delayed Job metrics (if using Delayed Job) - if defined?(Delayed::Job) - PrometheusExporter::Instrumentation::DelayedJob.start - end - - # Custom metrics collector for Password Pusher specific metrics - # This will be used to track pushes, views, etc. - class PasswordPusherMetricsCollector < PrometheusExporter::Server::TypeCollector - def initialize - @pushes_created = PrometheusExporter::Metric::Counter.new( - "pushes_created_total", - "Total number of pushes created" - ) - @pushes_viewed = PrometheusExporter::Metric::Counter.new( - "pushes_viewed_total", - "Total number of pushes viewed" - ) - @pushes_expired = PrometheusExporter::Metric::Counter.new( - "pushes_expired_total", - "Total number of pushes that have expired" - ) - end - - def type - "password_pusher" - end - - def collect(obj) - labels = obj["labels"] || {} - - case obj["action"] - when "push_created" - @pushes_created.observe(1, labels) - when "push_viewed" - @pushes_viewed.observe(1, labels) - when "push_expired" - @pushes_expired.observe(1, labels) - end - end - - def metrics - [@pushes_created, @pushes_viewed, @pushes_expired] - end - end end diff --git a/config/prometheus_server.rb b/config/prometheus_server.rb index 2ad17a27530..cce69942200 100644 --- a/config/prometheus_server.rb +++ b/config/prometheus_server.rb @@ -12,9 +12,6 @@ require "prometheus_exporter/server" require "prometheus_exporter/instrumentation" -# Load the custom collector -require_relative "../app/models/concerns/prometheus_metrics" - # Custom collector for Password Pusher metrics class PasswordPusherMetricsCollector < PrometheusExporter::Server::TypeCollector def initialize From 431fcb496050799d1c7e8e7e9a073e86ce6bb2ca Mon Sep 17 00:00:00 2001 From: Cedric Grard Date: Fri, 5 Dec 2025 11:55:12 +0100 Subject: [PATCH 07/11] Add notes on Puma stats errors in development mode to PROMETHEUS.md --- PROMETHEUS.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/PROMETHEUS.md b/PROMETHEUS.md index 2fdeda841a9..c7490d686c8 100644 --- a/PROMETHEUS.md +++ b/PROMETHEUS.md @@ -243,6 +243,16 @@ The Rails app can't connect to the Prometheus exporter. Check: 2. `PROMETHEUS_EXPORTER_HOST` and `PROMETHEUS_EXPORTER_PORT` match between Rails and exporter 3. Firewall rules aren't blocking localhost connections +### Puma stats errors in development + +In development mode, you may see warnings about Puma stats being unavailable. This is expected and harmless: + +```text +Puma metrics not available: undefined method 'stats' for nil +``` + +Puma metrics are automatically disabled in development because single-mode Puma doesn't support stats collection. In production with clustered Puma (multiple workers), Puma metrics will work correctly and provide valuable insights about thread pool usage and request backlog. + ## Production Recommendations 1. **Always run via Procfile** - Don't start processes manually in production From 17547067bc8234025602fd6f1607f09ea46e9c69 Mon Sep 17 00:00:00 2001 From: Cedric Grard Date: Fri, 5 Dec 2025 12:00:36 +0100 Subject: [PATCH 08/11] Add graceful shutdown handling for Prometheus Exporter server --- config/prometheus_server.rb | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/config/prometheus_server.rb b/config/prometheus_server.rb index cce69942200..2b2a6a51783 100644 --- a/config/prometheus_server.rb +++ b/config/prometheus_server.rb @@ -141,5 +141,16 @@ def metrics # Start the server server.start +# Handle graceful shutdown +trap("INT") do + puts "\nShutting down Prometheus Exporter..." + exit(0) +end + +trap("TERM") do + puts "\nShutting down Prometheus Exporter..." + exit(0) +end + # Keep the process running sleep From d82be9f23d3708017d10a89f09d917bb5721544b Mon Sep 17 00:00:00 2001 From: Cedric Grard Date: Fri, 5 Dec 2025 12:08:18 +0100 Subject: [PATCH 09/11] Add admin and owner view metrics for audit and compliance tracking --- METRICS.md | 91 +++++++++++++++++++++++++++++++++++++ app/models/audit_log.rb | 16 +++++++ config/prometheus_server.rb | 14 ++++++ 3 files changed, 121 insertions(+) diff --git a/METRICS.md b/METRICS.md index 61ad8c6437b..ce2bc1fa168 100644 --- a/METRICS.md +++ b/METRICS.md @@ -148,6 +148,75 @@ sum by (user_type) (pwpush_pushes_failed_passphrase_total) summary: "Potential passphrase brute force attack" ``` +### Administrative & Audit Metrics + +#### `pwpush_pushes_admin_view_total` + +Total number of admin views on pushes (for audit and compliance). + +**Labels:** + +- `push_kind` - Type of push: `text`, `file`, `url`, or `qr` +- `user_type` - Viewer type: `authenticated` or `anonymous` + +**Example queries:** + +```promql +# Total admin views +sum(pwpush_pushes_admin_view_total) + +# Admin view rate +rate(pwpush_pushes_admin_view_total[5m]) + +# Admin views by push type +sum by (push_kind) (pwpush_pushes_admin_view_total) + +# Ratio of admin views to regular views +sum(pwpush_pushes_admin_view_total) / sum(pwpush_pushes_viewed_total) +``` + +**Alert example:** + +```yaml +- alert: HighAdminViewActivity + expr: rate(pwpush_pushes_admin_view_total[1h]) > 50 + for: 5m + annotations: + summary: "Unusually high admin view activity detected" +``` + +#### `pwpush_pushes_owner_view_total` + +Total number of owner views on their own pushes (self-inspection tracking). + +**Labels:** + +- `push_kind` - Type of push: `text`, `file`, `url`, or `qr` +- `user_type` - Viewer type: `authenticated` or `anonymous` + +**Example queries:** + +```promql +# Total owner views +sum(pwpush_pushes_owner_view_total) + +# Owner view rate +rate(pwpush_pushes_owner_view_total[5m]) + +# Owner views by push type +sum by (push_kind) (pwpush_pushes_owner_view_total) + +# Percentage of users who check their own pushes +sum(pwpush_pushes_owner_view_total) / sum(pwpush_pushes_created_total) +``` + +**Example queries:** + +```promql +# Owner self-inspection behavior over time +increase(pwpush_pushes_owner_view_total[24h]) +``` + ### File Upload Metrics #### `pwpush_file_uploads_total` @@ -345,6 +414,28 @@ rate(pwpush_pushes_failed_view_total[5m]) # Account lockouts in last hour increase(pwpush_user_locked_total[1h]) + +# Admin view activity (audit trail) +rate(pwpush_pushes_admin_view_total[1h]) +``` + +### Audit & Compliance Dashboard + +```promql +# Total admin views today +increase(pwpush_pushes_admin_view_total[24h]) + +# Admin view rate +rate(pwpush_pushes_admin_view_total[5m]) + +# Admin to regular view ratio +sum(pwpush_pushes_admin_view_total) / sum(pwpush_pushes_viewed_total) + +# Owner self-inspection rate +increase(pwpush_pushes_owner_view_total[24h]) + +# Pushes checked by owners vs total created +sum(pwpush_pushes_owner_view_total) / sum(pwpush_pushes_created_total) ``` ### Business Metrics Dashboard diff --git a/app/models/audit_log.rb b/app/models/audit_log.rb index 3ec8f145b9b..33c4508bc07 100644 --- a/app/models/audit_log.rb +++ b/app/models/audit_log.rb @@ -12,6 +12,8 @@ class AuditLog < ApplicationRecord after_create :track_view_metric, if: :view? after_create :track_failed_view_metric, if: :failed_view? after_create :track_failed_passphrase_metric, if: :failed_passphrase? + after_create :track_admin_view_metric, if: :admin_view? + after_create :track_owner_view_metric, if: :owner_view? private @@ -38,6 +40,20 @@ def track_failed_passphrase_metric }) end + def track_admin_view_metric + PrometheusMetrics.track_metric("push_admin_view", { + push_kind: push.kind, + user_type: user_id.present? ? "authenticated" : "anonymous" + }) + end + + def track_owner_view_metric + PrometheusMetrics.track_metric("push_owner_view", { + push_kind: push.kind, + user_type: user_id.present? ? "authenticated" : "anonymous" + }) + end + def subject_name user&.email || "❓" end diff --git a/config/prometheus_server.rb b/config/prometheus_server.rb index 2b2a6a51783..d561a2d641f 100644 --- a/config/prometheus_server.rb +++ b/config/prometheus_server.rb @@ -36,6 +36,14 @@ def initialize "pwpush_pushes_failed_passphrase_total", "Total number of failed passphrase attempts" ) + @pushes_admin_view = PrometheusExporter::Metric::Counter.new( + "pwpush_pushes_admin_view_total", + "Total number of admin views on pushes" + ) + @pushes_owner_view = PrometheusExporter::Metric::Counter.new( + "pwpush_pushes_owner_view_total", + "Total number of owner views on their own pushes" + ) # File upload metrics @file_uploads_total = PrometheusExporter::Metric::Counter.new( @@ -94,6 +102,10 @@ def collect(obj) @pushes_failed_view.observe(1, labels) when "push_failed_passphrase" @pushes_failed_passphrase.observe(1, labels) + when "push_admin_view" + @pushes_admin_view.observe(1, labels) + when "push_owner_view" + @pushes_owner_view.observe(1, labels) when "user_signup" @user_signup.observe(1, labels) when "user_login_success" @@ -114,6 +126,8 @@ def metrics @pushes_expired, @pushes_failed_view, @pushes_failed_passphrase, + @pushes_admin_view, + @pushes_owner_view, @file_uploads_total, @file_upload_bytes, @user_signup, From bdd829969dc6e0552e03836aabd47a6eae55db71 Mon Sep 17 00:00:00 2001 From: Cedric Grard Date: Fri, 5 Dec 2025 15:41:20 +0100 Subject: [PATCH 10/11] Add Prometheus metrics tests for Push, AuditLog, and User models --- test/prometheus_test.rb | 164 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 test/prometheus_test.rb diff --git a/test/prometheus_test.rb b/test/prometheus_test.rb new file mode 100644 index 00000000000..4486370ab94 --- /dev/null +++ b/test/prometheus_test.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +require "test_helper" + +class PrometheusTest < ActiveSupport::TestCase + # PrometheusMetrics Concern Tests + + test "track_metric does nothing in test environment" do + # Metrics are disabled in test environment + assert_nothing_raised do + PrometheusMetrics.track_metric("test_action", {test_label: "test_value"}) + end + end + + # Push Tests + test "push creation does not break application" do + push = Push.create!( + kind: "text", + payload: "test_payload", + deletable_by_viewer: true, + retrieval_step: false + ) + + assert push.persisted? + assert_equal "text", push.kind + end + + test "push with passphrase does not break application" do + push = Push.create!( + kind: "text", + payload: "test_payload", + passphrase: "secret123", + deletable_by_viewer: true, + retrieval_step: false + ) + + assert push.persisted? + assert push.passphrase.present? + end + + test "push by authenticated user does not break application" do + user = users(:luca) + + push = Push.create!( + kind: "text", + payload: "test_payload", + user: user, + deletable_by_viewer: true, + retrieval_step: false + ) + + assert push.persisted? + assert_equal user.id, push.user_id + end + + test "push expiration does not break application" do + push = Push.create!( + kind: "text", + payload: "test_payload", + deletable_by_viewer: true, + retrieval_step: false, + expired: false + ) + + push.update!(expired: true) + + assert push.expired? + end + + # AuditLog Tests + test "audit log view does not break application" do + push = Push.create!( + kind: "text", + payload: "test_payload", + deletable_by_viewer: true, + retrieval_step: false + ) + + audit_log = AuditLog.create!(kind: :view, push: push) + + assert audit_log.persisted? + assert_equal "view", audit_log.kind + end + + test "audit log failed_view does not break application" do + push = Push.create!(kind: "text", payload: "test", deletable_by_viewer: true, retrieval_step: false) + audit_log = AuditLog.create!(kind: :failed_view, push: push) + + assert audit_log.persisted? + assert_equal "failed_view", audit_log.kind + end + + test "audit log failed_passphrase does not break application" do + push = Push.create!(kind: "text", payload: "test", deletable_by_viewer: true, retrieval_step: false) + audit_log = AuditLog.create!(kind: :failed_passphrase, push: push) + + assert audit_log.persisted? + assert_equal "failed_passphrase", audit_log.kind + end + + test "audit log admin_view does not break application" do + push = Push.create!(kind: "text", payload: "test", deletable_by_viewer: true, retrieval_step: false) + audit_log = AuditLog.create!(kind: :admin_view, push: push) + + assert audit_log.persisted? + assert_equal "admin_view", audit_log.kind + end + + test "audit log owner_view does not break application" do + push = Push.create!(kind: "text", payload: "test", deletable_by_viewer: true, retrieval_step: false) + audit_log = AuditLog.create!(kind: :owner_view, push: push) + + assert audit_log.persisted? + assert_equal "owner_view", audit_log.kind + end + + # User Tests + test "user signup does not break application" do + user = User.new( + email: "test@example.com", + password: "SecurePassword123!", + password_confirmation: "SecurePassword123!" + ) + user.skip_confirmation! + + assert user.save + assert_equal "test@example.com", user.email + end + + test "user signup with preferred language does not break application" do + user = User.new( + email: "test-fr@example.com", + password: "SecurePassword123!", + password_confirmation: "SecurePassword123!", + preferred_language: "fr" + ) + user.skip_confirmation! + + assert user.save + assert_equal "fr", user.preferred_language + end + + test "user account lockout does not break application" do + user = users(:luca) + + user.update!(locked_at: Time.current) + + assert user.locked_at.present? + assert user.access_locked? + end + + # Model Integration Tests + test "Push model includes PrometheusMetrics concern" do + assert Push.included_modules.include?(PrometheusMetrics) + end + + test "AuditLog model includes PrometheusMetrics concern" do + assert AuditLog.included_modules.include?(PrometheusMetrics) + end + + test "User model includes PrometheusMetrics concern" do + assert User.included_modules.include?(PrometheusMetrics) + end +end From 066c09fd4cc9805c19faf6626f408271ecc08db5 Mon Sep 17 00:00:00 2001 From: Cedric Grard Date: Fri, 5 Dec 2025 16:14:03 +0100 Subject: [PATCH 11/11] Fix subject_name method visibility in AuditLog model The subject_name method was incorrectly placed in the private section after adding Prometheus tracking callbacks, causing errors when the method was called from view templates (app/views/audit_logs/_log_creation.html.erb). Moved subject_name back to the public section to restore proper access from views while keeping Prometheus tracking methods private. Fixes 16 failing tests across OwnerAndAdminViewTest, QrAuditTest, AuditLogTest, and PasswordAuditTest. --- app/models/audit_log.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/models/audit_log.rb b/app/models/audit_log.rb index 33c4508bc07..9aef578c5a3 100644 --- a/app/models/audit_log.rb +++ b/app/models/audit_log.rb @@ -15,6 +15,10 @@ class AuditLog < ApplicationRecord after_create :track_admin_view_metric, if: :admin_view? after_create :track_owner_view_metric, if: :owner_view? + def subject_name + user&.email || "❓" + end + private def track_view_metric @@ -53,8 +57,4 @@ def track_owner_view_metric user_type: user_id.present? ? "authenticated" : "anonymous" }) end - - def subject_name - user&.email || "❓" - end end