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/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 diff --git a/METRICS.md b/METRICS.md new file mode 100644 index 00000000000..ce2bc1fa168 --- /dev/null +++ b/METRICS.md @@ -0,0 +1,643 @@ +# 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" +``` + +### 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` + +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]) + +# 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 + +```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 new file mode 100644 index 00000000000..c7490d686c8 --- /dev/null +++ b/PROMETHEUS.md @@ -0,0 +1,302 @@ +# Prometheus Metrics Setup + +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`** + +For detailed information about available metrics, see [METRICS.md](METRICS.md). + +## 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`. + +## 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 +``` + +## 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 + +### 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 +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 custom events +PrometheusMetrics.track_metric("custom_event", { + category: "example" +}) +``` + +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. + +## 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/) +- [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 61ce6b43cc6..9aef578c5a3 100644 --- a/app/models/audit_log.rb +++ b/app/models/audit_log.rb @@ -1,12 +1,60 @@ # frozen_string_literal: true class AuditLog < ApplicationRecord + include PrometheusMetrics + enum :kind, [:creation, :view, :failed_view, :expire, :failed_passphrase, :admin_view, :owner_view], validate: true belongs_to :push belongs_to :user, optional: true + # 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? + 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 + PrometheusMetrics.track_metric("push_viewed", { + push_kind: push.kind, + 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 + + 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 end diff --git a/app/models/concerns/prometheus_metrics.rb b/app/models/concerns/prometheus_metrics.rb new file mode 100644 index 00000000000..cb23f508884 --- /dev/null +++ b/app/models/concerns/prometheus_metrics.rb @@ -0,0 +1,61 @@ +# 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 + labels = { + kind: kind, + 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 + + # 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, + had_passphrase: passphrase_ciphertext.present? ? "yes" : "no" + }) + 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/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/initializers/prometheus.rb b/config/initializers/prometheus.rb new file mode 100644 index 00000000000..c9b9e8d2908 --- /dev/null +++ b/config/initializers/prometheus.rb @@ -0,0 +1,41 @@ +# 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" + + # 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 (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.) + # 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) + PrometheusExporter::Instrumentation::ActiveRecord.start( + custom_labels: {app: "password_pusher"}, + config_labels: [:database, :host] + ) + end +end diff --git a/config/prometheus_server.rb b/config/prometheus_server.rb new file mode 100644 index 00000000000..d561a2d641f --- /dev/null +++ b/config/prometheus_server.rb @@ -0,0 +1,170 @@ +#!/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" + +# 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 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" + ) + @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( + "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 + "password_pusher" + end + + def collect(obj) + labels = obj["labels"] || {} + + 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 "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" + @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_failed_view, + @pushes_failed_passphrase, + @pushes_admin_view, + @pushes_owner_view, + @file_uploads_total, + @file_upload_bytes, + @user_signup, + @user_login_success, + @user_login_failed, + @user_logout, + @user_locked + ] + 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 + +# 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 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 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