Files
flyer-crawler.projectium.com/docker/logstash/bugsink.conf
Torben Sorensen d03900cefe
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 19m4s
set PST as common time zone for log matching ease
2026-01-22 23:38:45 -08:00

575 lines
23 KiB
Plaintext

# docker/logstash/bugsink.conf
# ============================================================================
# Logstash Pipeline Configuration for Bugsink Error Tracking
# ============================================================================
# This configuration aggregates logs from multiple sources and forwards errors
# to Bugsink (Sentry-compatible error tracking) in the dev container.
#
# Sources:
# - PM2 managed logs (/var/log/pm2/*.log) - API, Worker, Vite (ADR-014)
# - Pino application logs (/app/logs/*.log) - JSON format (fallback)
# - PostgreSQL logs (/var/log/postgresql/*.log) - Including fn_log() output
# - NGINX logs (/var/log/nginx/*.log) - Access and error logs
# - Redis logs (/var/log/redis/*.log) - Via shared volume (ADR-050)
#
# Bugsink Projects (3-project architecture):
# - Project 1: Backend API (Dev) - Pino/PM2 app errors, PostgreSQL errors
# DSN Key: cea01396c56246adb5878fa5ee6b1d22
# - Project 2: Frontend (Dev) - Configured via Sentry SDK in browser
# DSN Key: d92663cb73cf4145b677b84029e4b762
# - Project 4: Infrastructure (Dev) - Redis, NGINX, PM2 operational logs
# DSN Key: 14e8791da3d347fa98073261b596cab9
#
# Routing Logic:
# - Backend logs (type: pm2_api, pm2_worker, pino, postgres) -> Project 1
# - Infrastructure logs (type: redis, nginx_error, nginx_5xx) -> Project 4
# - Vite errors (type: pm2_vite with errors) -> Project 4 (build tooling)
#
# Related Documentation:
# - docs/adr/0050-postgresql-function-observability.md
# - docs/adr/0015-application-performance-monitoring-and-error-tracking.md
# - docs/operations/LOGSTASH-QUICK-REF.md
# ============================================================================
input {
# ============================================================================
# PM2 Managed Process Logs (ADR-014, ADR-050)
# ============================================================================
# PM2 manages all Node.js processes in the dev container, matching production.
# Logs are written to /var/log/pm2 for Logstash integration.
#
# Process logs:
# - api-out.log / api-error.log: API server (Pino JSON format)
# - worker-out.log / worker-error.log: Background worker (Pino JSON format)
# - vite-out.log / vite-error.log: Vite dev server (plain text)
# ============================================================================
# PM2 API Server Logs (Pino JSON format)
file {
path => "/var/log/pm2/api-out.log"
codec => json_lines
type => "pm2_api"
tags => ["app", "backend", "pm2", "api"]
start_position => "beginning"
sincedb_path => "/var/lib/logstash/sincedb_pm2_api_out"
}
file {
path => "/var/log/pm2/api-error.log"
codec => json_lines
type => "pm2_api"
tags => ["app", "backend", "pm2", "api", "stderr"]
start_position => "beginning"
sincedb_path => "/var/lib/logstash/sincedb_pm2_api_error"
}
# PM2 Worker Logs (Pino JSON format)
file {
path => "/var/log/pm2/worker-out.log"
codec => json_lines
type => "pm2_worker"
tags => ["app", "worker", "pm2"]
start_position => "beginning"
sincedb_path => "/var/lib/logstash/sincedb_pm2_worker_out"
}
file {
path => "/var/log/pm2/worker-error.log"
codec => json_lines
type => "pm2_worker"
tags => ["app", "worker", "pm2", "stderr"]
start_position => "beginning"
sincedb_path => "/var/lib/logstash/sincedb_pm2_worker_error"
}
# PM2 Vite Logs (plain text format)
file {
path => "/var/log/pm2/vite-out.log"
codec => plain
type => "pm2_vite"
tags => ["app", "frontend", "pm2", "vite"]
start_position => "beginning"
sincedb_path => "/var/lib/logstash/sincedb_pm2_vite_out"
}
file {
path => "/var/log/pm2/vite-error.log"
codec => plain
type => "pm2_vite"
tags => ["app", "frontend", "pm2", "vite", "stderr"]
start_position => "beginning"
sincedb_path => "/var/lib/logstash/sincedb_pm2_vite_error"
}
# ============================================================================
# Pino Application Logs (Fallback Path)
# ============================================================================
# JSON-formatted logs from the Node.js application using Pino logger.
# Note: Primary logs now go to /var/log/pm2. This is a fallback.
# Log levels: 10=trace, 20=debug, 30=info, 40=warn, 50=error, 60=fatal
file {
path => "/app/logs/*.log"
codec => json
type => "pino"
tags => ["app", "backend"]
start_position => "beginning"
sincedb_path => "/var/lib/logstash/sincedb_pino"
}
# ============================================================================
# PostgreSQL Function Logs (ADR-050)
# ============================================================================
# Captures PostgreSQL log output including fn_log() structured JSON messages.
# PostgreSQL is configured to write logs to /var/log/postgresql/ (shared volume).
# Log format: "2026-01-22 14:30:00 PST [5724] postgres@flyer_crawler_dev LOG: message"
# Note: Timestamps are in PST (America/Los_Angeles) timezone as configured in compose.dev.yml
file {
path => "/var/log/postgresql/*.log"
type => "postgres"
tags => ["postgres", "database"]
start_position => "beginning"
sincedb_path => "/var/lib/logstash/sincedb_postgres"
}
# ============================================================================
# NGINX Logs
# ============================================================================
# Access logs for request monitoring and error logs for proxy/SSL issues.
file {
path => "/var/log/nginx/access.log"
type => "nginx_access"
tags => ["nginx", "access"]
start_position => "beginning"
sincedb_path => "/var/lib/logstash/sincedb_nginx_access"
}
file {
path => "/var/log/nginx/error.log"
type => "nginx_error"
tags => ["nginx", "error"]
start_position => "beginning"
sincedb_path => "/var/lib/logstash/sincedb_nginx_error"
}
# ============================================================================
# Redis Logs (ADR-050)
# ============================================================================
# Redis logs from the shared volume. Redis uses its own log format:
# "<pid>:<role> <day> <month> <time> <loglevel> <message>"
# Example: "1:M 22 Jan 2026 14:30:00.123 * Ready to accept connections"
# Log levels: . (debug), - (verbose), * (notice), # (warning)
file {
path => "/var/log/redis/redis-server.log"
type => "redis"
tags => ["redis", "infra"]
start_position => "beginning"
sincedb_path => "/var/lib/logstash/sincedb_redis"
}
}
filter {
# ============================================================================
# PM2 API/Worker Log Processing (ADR-014)
# ============================================================================
# PM2 API and Worker logs are in Pino JSON format.
# Process them the same way as direct Pino logs.
if [type] in ["pm2_api", "pm2_worker"] {
# Tag errors (level 50 = error, 60 = fatal)
if [level] >= 50 {
mutate { add_tag => ["error", "pm2_pino_error"] }
# Map Pino level to Sentry level and set error_message field
if [level] == 60 {
mutate { add_field => { "sentry_level" => "fatal" } }
} else {
mutate { add_field => { "sentry_level" => "error" } }
}
# Copy msg to error_message for consistent access in output
mutate { add_field => { "error_message" => "%{msg}" } }
}
}
# ============================================================================
# PM2 Vite Log Processing (ADR-014)
# ============================================================================
# Vite logs are plain text. Look for error patterns.
if [type] == "pm2_vite" {
# Detect Vite build/compilation errors
if [message] =~ /(?i)(error|failed|exception|cannot|enoent|eperm|EACCES|ECONNREFUSED)/ {
mutate { add_tag => ["error", "vite_error"] }
mutate { add_field => { "sentry_level" => "error" } }
mutate { add_field => { "error_message" => "Vite: %{message}" } }
}
}
# ============================================================================
# Pino Log Processing (Fallback)
# ============================================================================
if [type] == "pino" {
# Tag errors (level 50 = error, 60 = fatal)
if [level] >= 50 {
mutate { add_tag => ["error", "pino_error"] }
# Map Pino level to Sentry level and set error_message field
if [level] == 60 {
mutate { add_field => { "sentry_level" => "fatal" } }
} else {
mutate { add_field => { "sentry_level" => "error" } }
}
# Copy msg to error_message for consistent access in output
mutate { add_field => { "error_message" => "%{msg}" } }
}
}
# ============================================================================
# PostgreSQL Log Processing (ADR-050)
# ============================================================================
# PostgreSQL log format in dev container:
# "2026-01-22 14:30:00 PST [5724] postgres@flyer_crawler_dev LOG: message"
# "2026-01-22 15:06:03 PST [19851] postgres@flyer_crawler_dev ERROR: column "id" does not exist"
# Note: Timestamps are in PST (America/Los_Angeles) timezone
if [type] == "postgres" {
# Parse PostgreSQL log prefix with timezone (PST in dev, may vary in prod)
grok {
match => { "message" => "%{YEAR}-%{MONTHNUM}-%{MONTHDAY} %{TIME} %{WORD:pg_timezone} \[%{POSINT:pg_pid}\] %{DATA:pg_user}@%{DATA:pg_database} %{WORD:pg_level}: ?%{GREEDYDATA:pg_message}" }
tag_on_failure => ["_postgres_grok_failure"]
}
# Check if this is a structured JSON log from fn_log()
if [pg_message] =~ /^\{.*"source":"postgresql".*\}$/ {
json {
source => "pg_message"
target => "fn_log"
}
# Mark as error if level is WARNING or ERROR
if [fn_log][level] in ["WARNING", "ERROR"] {
mutate { add_tag => ["error", "db_function"] }
mutate { add_field => { "sentry_level" => "warning" } }
if [fn_log][level] == "ERROR" {
mutate { replace => { "sentry_level" => "error" } }
}
# Use fn_log message for error_message
mutate { add_field => { "error_message" => "%{[fn_log][message]}" } }
}
}
# Catch native PostgreSQL errors (ERROR: or FATAL: prefix in pg_level)
if [pg_level] == "ERROR" or [pg_level] == "FATAL" {
mutate { add_tag => ["error", "postgres_native"] }
if [pg_level] == "FATAL" {
mutate { add_field => { "sentry_level" => "fatal" } }
} else {
mutate { add_field => { "sentry_level" => "error" } }
}
# Use the full pg_message for error_message
mutate { add_field => { "error_message" => "PostgreSQL %{pg_level}: %{pg_message}" } }
}
}
# ============================================================================
# NGINX Access Log Processing
# ============================================================================
if [type] == "nginx_access" {
grok {
match => { "message" => '%{IPORHOST:client_ip} - %{DATA:user} \[%{HTTPDATE:timestamp}\] "%{WORD:method} %{URIPATHPARAM:request} HTTP/%{NUMBER:http_version}" %{NUMBER:status} %{NUMBER:bytes} "%{DATA:referrer}" "%{DATA:user_agent}"' }
tag_on_failure => ["_nginx_access_grok_failure"]
}
# Tag 5xx errors for Bugsink
if [status] =~ /^5/ {
mutate { add_tag => ["error", "nginx_5xx"] }
mutate { add_field => { "sentry_level" => "error" } }
mutate { add_field => { "error_message" => "HTTP %{status} %{method} %{request}" } }
}
}
# ============================================================================
# NGINX Error Log Processing
# ============================================================================
# NGINX error log format: "2026/01/22 17:55:01 [error] 16#16: *3 message..."
if [type] == "nginx_error" {
grok {
match => { "message" => "%{YEAR}/%{MONTHNUM}/%{MONTHDAY} %{TIME} \[%{LOGLEVEL:nginx_level}\] %{POSINT:pid}#%{POSINT}: (\*%{POSINT:connection} )?%{GREEDYDATA:nginx_message}" }
tag_on_failure => ["_nginx_error_grok_failure"]
}
# Only process actual errors, not notices (like "signal process started")
if [nginx_level] in ["error", "crit", "alert", "emerg"] {
mutate { add_tag => ["error", "nginx_error"] }
# Map NGINX log level to Sentry level
if [nginx_level] in ["crit", "alert", "emerg"] {
mutate { add_field => { "sentry_level" => "fatal" } }
} else {
mutate { add_field => { "sentry_level" => "error" } }
}
mutate { add_field => { "error_message" => "NGINX [%{nginx_level}]: %{nginx_message}" } }
}
}
# ============================================================================
# Redis Log Processing (ADR-050)
# ============================================================================
# Redis log format: "<pid>:<role> <day> <month> <time>.<ms> <level> <message>"
# Example: "1:M 22 Jan 14:30:00.123 * Ready to accept connections"
# Roles: M=master, S=slave, C=sentinel, X=cluster
# Levels: . (debug), - (verbose), * (notice), # (warning)
if [type] == "redis" {
# Parse Redis log format
grok {
match => { "message" => "%{POSINT:redis_pid}:%{WORD:redis_role} %{MONTHDAY} %{MONTH} %{YEAR}? ?%{TIME} %{DATA:redis_level_char} %{GREEDYDATA:redis_message}" }
tag_on_failure => ["_redis_grok_failure"]
}
# Map Redis level characters to human-readable levels
# . = debug, - = verbose, * = notice, # = warning
if [redis_level_char] == "#" {
mutate {
add_field => { "redis_level" => "warning" }
add_tag => ["error", "redis_warning"]
}
mutate { add_field => { "sentry_level" => "warning" } }
mutate { add_field => { "error_message" => "Redis WARNING: %{redis_message}" } }
} else if [redis_level_char] == "*" {
mutate { add_field => { "redis_level" => "notice" } }
} else if [redis_level_char] == "-" {
mutate { add_field => { "redis_level" => "verbose" } }
} else if [redis_level_char] == "." {
mutate { add_field => { "redis_level" => "debug" } }
}
# Also detect error keywords in message content (e.g., ECONNREFUSED, OOM, etc.)
if [redis_message] =~ /(?i)(error|failed|refused|denied|timeout|oom|crash|fatal|exception)/ {
if "error" not in [tags] {
mutate { add_tag => ["error", "redis_error"] }
mutate { add_field => { "sentry_level" => "error" } }
mutate { add_field => { "error_message" => "Redis ERROR: %{redis_message}" } }
}
}
}
# ============================================================================
# Generate Sentry Event ID and Ensure Required Fields for all errors
# ============================================================================
# CRITICAL: sentry_level MUST be set for all errors before output.
# Bugsink's PostgreSQL schema limits level to varchar(7), so valid values are:
# fatal, error, warning, info, debug (all <= 7 chars)
# If sentry_level is not set, the literal "%{sentry_level}" (16 chars) is sent,
# causing PostgreSQL insertion failures.
# ============================================================================
if "error" in [tags] {
# Use Ruby for robust field handling - handles all edge cases
ruby {
code => '
require "securerandom"
# Generate unique event ID for Sentry
event.set("sentry_event_id", SecureRandom.hex(16))
# =====================================================================
# CRITICAL: Validate and set sentry_level
# =====================================================================
# Valid Sentry levels (max 7 chars for Bugsink PostgreSQL schema):
# fatal, error, warning, info, debug
# Default to "error" if missing, empty, or invalid.
# =====================================================================
valid_levels = ["fatal", "error", "warning", "info", "debug"]
current_level = event.get("sentry_level")
if current_level.nil? || current_level.to_s.strip.empty? || !valid_levels.include?(current_level.to_s.downcase)
event.set("sentry_level", "error")
else
# Normalize to lowercase
event.set("sentry_level", current_level.to_s.downcase)
end
# =====================================================================
# Ensure error_message has a fallback value
# =====================================================================
error_msg = event.get("error_message")
if error_msg.nil? || error_msg.to_s.strip.empty?
fallback_msg = event.get("message") || event.get("msg") || "Unknown error"
event.set("error_message", fallback_msg.to_s)
end
'
}
}
}
output {
# ============================================================================
# Forward Errors to Bugsink (Project Routing)
# ============================================================================
# Bugsink uses Sentry-compatible API. Events must include:
# - event_id: 32 hex characters (UUID without dashes)
# - message: Human-readable error description
# - level: fatal, error, warning, info, debug
# - timestamp: Unix epoch in seconds
# - platform: "node" for backend, "javascript" for frontend
#
# Authentication via X-Sentry-Auth header with project's public key.
#
# Project Routing:
# - Project 1 (Backend): Pino app logs, PostgreSQL errors
# - Project 4 (Infrastructure): Redis, NGINX, Vite build errors
# ============================================================================
# ============================================================================
# Infrastructure Errors -> Project 4
# ============================================================================
# Redis warnings/errors, NGINX errors, and Vite build errors go to
# the Infrastructure project for separation from application code errors.
if "error" in [tags] and ([type] == "redis" or [type] == "nginx_error" or [type] == "nginx_access" or [type] == "pm2_vite") {
http {
url => "http://localhost:8000/api/4/store/"
http_method => "post"
format => "json"
headers => {
"X-Sentry-Auth" => "Sentry sentry_key=14e8791da3d347fa98073261b596cab9, sentry_version=7"
"Content-Type" => "application/json"
}
mapping => {
"event_id" => "%{sentry_event_id}"
"timestamp" => "%{@timestamp}"
"level" => "%{sentry_level}"
"platform" => "other"
"logger" => "%{type}"
"message" => "%{error_message}"
"extra" => {
"hostname" => "%{[host][name]}"
"source_type" => "%{type}"
"tags" => "%{tags}"
"original_message" => "%{message}"
"project" => "infrastructure"
}
}
}
}
# ============================================================================
# Backend Application Errors -> Project 1
# ============================================================================
# Pino application logs (API, Worker), PostgreSQL function errors, and
# native PostgreSQL errors go to the Backend API project.
else if "error" in [tags] and ([type] in ["pm2_api", "pm2_worker", "pino", "postgres"]) {
http {
url => "http://localhost:8000/api/1/store/"
http_method => "post"
format => "json"
headers => {
"X-Sentry-Auth" => "Sentry sentry_key=cea01396c56246adb5878fa5ee6b1d22, sentry_version=7"
"Content-Type" => "application/json"
}
mapping => {
"event_id" => "%{sentry_event_id}"
"timestamp" => "%{@timestamp}"
"level" => "%{sentry_level}"
"platform" => "node"
"logger" => "%{type}"
"message" => "%{error_message}"
"extra" => {
"hostname" => "%{[host][name]}"
"source_type" => "%{type}"
"tags" => "%{tags}"
"original_message" => "%{message}"
"project" => "backend"
}
}
}
}
# ============================================================================
# Fallback: Any other errors -> Project 1
# ============================================================================
# Catch-all for any errors that don't match specific routing rules.
else if "error" in [tags] {
http {
url => "http://localhost:8000/api/1/store/"
http_method => "post"
format => "json"
headers => {
"X-Sentry-Auth" => "Sentry sentry_key=cea01396c56246adb5878fa5ee6b1d22, sentry_version=7"
"Content-Type" => "application/json"
}
mapping => {
"event_id" => "%{sentry_event_id}"
"timestamp" => "%{@timestamp}"
"level" => "%{sentry_level}"
"platform" => "node"
"logger" => "%{type}"
"message" => "%{error_message}"
"extra" => {
"hostname" => "%{[host][name]}"
"source_type" => "%{type}"
"tags" => "%{tags}"
"original_message" => "%{message}"
"project" => "backend-fallback"
}
}
}
}
# ============================================================================
# Store Operational Logs to Files (for debugging/audit)
# ============================================================================
# NGINX access logs (all requests, not just errors)
if [type] == "nginx_access" and "error" not in [tags] {
file {
path => "/var/log/logstash/nginx-access-%{+YYYY-MM-dd}.log"
codec => json_lines
}
}
# PostgreSQL operational logs (non-error)
if [type] == "postgres" and "error" not in [tags] {
file {
path => "/var/log/logstash/postgres-operational-%{+YYYY-MM-dd}.log"
codec => json_lines
}
}
# Redis operational logs (non-error)
if [type] == "redis" and "error" not in [tags] {
file {
path => "/var/log/logstash/redis-operational-%{+YYYY-MM-dd}.log"
codec => json_lines
}
}
# PM2 API operational logs (non-error) - ADR-014
if [type] == "pm2_api" and "error" not in [tags] {
file {
path => "/var/log/logstash/pm2-api-%{+YYYY-MM-dd}.log"
codec => json_lines
}
}
# PM2 Worker operational logs (non-error) - ADR-014
if [type] == "pm2_worker" and "error" not in [tags] {
file {
path => "/var/log/logstash/pm2-worker-%{+YYYY-MM-dd}.log"
codec => json_lines
}
}
# PM2 Vite operational logs (non-error) - ADR-014
if [type] == "pm2_vite" and "error" not in [tags] {
file {
path => "/var/log/logstash/pm2-vite-%{+YYYY-MM-dd}.log"
codec => json_lines
}
}
# ============================================================================
# Debug Output (for development only)
# ============================================================================
# Uncomment to see all processed events in Logstash stdout
# stdout { codec => rubydebug }
}