From 3ad93a93fbc0c9add47cd1b5bfb11cf6b45a3f84 Mon Sep 17 00:00:00 2001 From: Victor Date: Sat, 4 Apr 2026 12:52:42 -0400 Subject: [PATCH 01/14] prometheus and otel basic setup --- docker-compose.yml | 23 +++++++++++++++++++++++ frontend/docker/Dockerfile | 27 +++++++++++++++++++++++++++ frontend/next.config.ts | 2 +- otel/config.yaml | 32 ++++++++++++++++++++++++++++++++ prometheus/prometheus.yml | 16 ++++++++++++++++ 5 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 docker-compose.yml create mode 100644 frontend/docker/Dockerfile create mode 100644 otel/config.yaml create mode 100644 prometheus/prometheus.yml diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..66aff0cc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +name: url-shortner +services: + frontend: + build: + context: ./frontend + dockerfile: docker/Dockerfile + ports: + - "3000:3000" + + prometheus: + image: prom/prometheus:latest + ports: + - 9090:9090 + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml + + otel: + image: otel/opentelemetry-collector-contrib:latest + volumes: + - ./otel/config.yaml:/etc/otelcol-contrib/config.yaml + ports: + - 8889:8889 + - 4318:4318 diff --git a/frontend/docker/Dockerfile b/frontend/docker/Dockerfile new file mode 100644 index 00000000..165f1e33 --- /dev/null +++ b/frontend/docker/Dockerfile @@ -0,0 +1,27 @@ +FROM node:20-alpine AS deps +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +FROM node:20-alpine AS build +WORKDIR /app + +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +FROM node:20-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 + +COPY --from=build /app/public ./public +COPY --from=build /app/.next/standalone ./ +COPY --from=build /app/.next/static ./.next/static + +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/frontend/next.config.ts b/frontend/next.config.ts index e9ffa308..68a6c64d 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + output: "standalone", }; export default nextConfig; diff --git a/otel/config.yaml b/otel/config.yaml new file mode 100644 index 00000000..f30eb51f --- /dev/null +++ b/otel/config.yaml @@ -0,0 +1,32 @@ +receivers: # push based + otlp: + protocols: + http: + endpoint: localhost:4318 # Otel hosted endpoint + prometheus: + config: + scrape_configs: + - job_name: otel-collector + scrape_interval: 5s + static_configs: + - targets: [localhost:8888] + +exporters: # pull based + prometheus: + endpoint: 0.0.0.0:8889 # prometheus will scrape from here + debug: + verbosity: detailed + +service: + pipelines: + metrics: + receivers: [otlp] + processors: [batch, memory_limiter] + exporters: [prometheus] + +processors: + memory_limiter: + check_interval: 5s + limit_mib: 4000 + spike_limit_mib: 500 + batch: \ No newline at end of file diff --git a/prometheus/prometheus.yml b/prometheus/prometheus.yml new file mode 100644 index 00000000..baf5d7eb --- /dev/null +++ b/prometheus/prometheus.yml @@ -0,0 +1,16 @@ +global: + scrape_interval: 15s # By default, scrape targets every 15 seconds. + +scrape_configs: + # Job to scrape Prometheus's own metrics + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] # Prometheus server default port + + # Job to scrape metrics from the OpenTelemetry Collector + - job_name: 'otel-collector' + static_configs: + # The OTel Collector usually exposes Prometheus metrics at a specific endpoint/port + - targets: ['otel:8889'] # Common default for OTel Collector Prometheus exporter + # Example: If OTel endpoint has a specific metrics path, define it here + metrics_path: /metrics From a8b053221a91041ad0f7be8be97ff6c15f7ce5c9 Mon Sep 17 00:00:00 2001 From: Victor Date: Sat, 4 Apr 2026 13:12:33 -0400 Subject: [PATCH 02/14] process exporter and node exporter --- docker-compose.yml | 15 +++++++++++++++ otel/config.yaml | 17 +++++------------ prometheus/process-exporter.yml | 20 ++++++++++++++++++++ prometheus/prometheus.yml | 10 ++++++++++ 4 files changed, 50 insertions(+), 12 deletions(-) create mode 100644 prometheus/process-exporter.yml diff --git a/docker-compose.yml b/docker-compose.yml index 66aff0cc..b3ecc624 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,21 @@ services: - 9090:9090 volumes: - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml + + node-exporter: + image: prom/node-exporter:latest + ports: + - 9100:9100 + + process-exporter: + image: ncabatoff/process-exporter:latest + command: + - --config.path + - /etc/process-exporter/config.yml + volumes: + - ./prometheus/process-exporter.yml:/etc/process-exporter/config.yml:ro + ports: + - 9256:9256 otel: image: otel/opentelemetry-collector-contrib:latest diff --git a/otel/config.yaml b/otel/config.yaml index f30eb51f..43eab295 100644 --- a/otel/config.yaml +++ b/otel/config.yaml @@ -1,19 +1,12 @@ -receivers: # push based +receivers: otlp: protocols: http: - endpoint: localhost:4318 # Otel hosted endpoint - prometheus: - config: - scrape_configs: - - job_name: otel-collector - scrape_interval: 5s - static_configs: - - targets: [localhost:8888] - -exporters: # pull based + endpoint: 0.0.0.0:4318 + +exporters: prometheus: - endpoint: 0.0.0.0:8889 # prometheus will scrape from here + endpoint: 0.0.0.0:8889 debug: verbosity: detailed diff --git a/prometheus/process-exporter.yml b/prometheus/process-exporter.yml new file mode 100644 index 00000000..841e68ac --- /dev/null +++ b/prometheus/process-exporter.yml @@ -0,0 +1,20 @@ +process_names: + - name: "python" + cmdline: + - ".*python.*" + + - name: "node" + cmdline: + - ".*node.*" + + - name: "nginx" + cmdline: + - ".*nginx.*" + + - name: "postgres" + cmdline: + - ".*postgres.*" + + - name: "otelcol" + cmdline: + - ".*otelcol.*" diff --git a/prometheus/prometheus.yml b/prometheus/prometheus.yml index baf5d7eb..6ebc2153 100644 --- a/prometheus/prometheus.yml +++ b/prometheus/prometheus.yml @@ -14,3 +14,13 @@ scrape_configs: - targets: ['otel:8889'] # Common default for OTel Collector Prometheus exporter # Example: If OTel endpoint has a specific metrics path, define it here metrics_path: /metrics + + - job_name: 'node-exporter' + static_configs: + - targets: ['node-exporter:9100'] + metrics_path: /metrics + + - job_name: 'process-exporter' + static_configs: + - targets: ['process-exporter:9256'] + metrics_path: /metrics From 9d7a3d7d1dd73d1ebeee77a3b98702d137fd21be Mon Sep 17 00:00:00 2001 From: Victor Date: Sat, 4 Apr 2026 13:18:04 -0400 Subject: [PATCH 03/14] url shortner issues --- docker-compose.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index b3ecc624..6374d093 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,8 @@ services: node-exporter: image: prom/node-exporter:latest + command: + - --no-collector.kernel_hung ports: - 9100:9100 From d7000608a473c58b1f113da4d97c7f0aadd0531d Mon Sep 17 00:00:00 2001 From: Victor Date: Sat, 4 Apr 2026 14:50:21 -0400 Subject: [PATCH 04/14] frontend observability --- app/routes/links.py | 3 + docker-compose.yml | 19 ++++ grafana/dashboards/overview.json | 101 ++++++++++++++++++ .../provisioning/dashboards/dashboards.yml | 11 ++ .../provisioning/datasources/prometheus.yml | 10 ++ prometheus/process-exporter.yml | 26 +++-- prometheus/prometheus.yml | 6 +- 7 files changed, 161 insertions(+), 15 deletions(-) create mode 100644 grafana/dashboards/overview.json create mode 100644 grafana/provisioning/dashboards/dashboards.yml create mode 100644 grafana/provisioning/datasources/prometheus.yml diff --git a/app/routes/links.py b/app/routes/links.py index 36dac448..4714d8cf 100644 --- a/app/routes/links.py +++ b/app/routes/links.py @@ -50,6 +50,7 @@ def shorten(): return jsonify(error="url must start with http:// or https://"), 400 # Dedup: return existing code if URL already active + # ! Should add new entry even if URL exists existing = URL.get_or_none(URL.original_url == original_url, URL.is_active == True) if existing: return jsonify( @@ -60,6 +61,8 @@ def shorten(): ) short_code = _generate_short_code() + + # ! should add measure to prevent infinite loop while URL.select().where(URL.short_code == short_code).exists(): short_code = _generate_short_code() diff --git a/docker-compose.yml b/docker-compose.yml index 6374d093..a46e8f44 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,7 @@ services: process-exporter: image: ncabatoff/process-exporter:latest + pid: host command: - --config.path - /etc/process-exporter/config.yml @@ -38,3 +39,21 @@ services: ports: - 8889:8889 - 4318:4318 + + grafana: + image: grafana/grafana:latest + depends_on: + - prometheus + ports: + - "3001:3000" + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin + volumes: + - grafana-data:/var/lib/grafana + - ./grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro + - ./grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro + - ./grafana/dashboards:/var/lib/grafana/dashboards:ro + +volumes: + grafana-data: diff --git a/grafana/dashboards/overview.json b/grafana/dashboards/overview.json new file mode 100644 index 00000000..8c8c8b09 --- /dev/null +++ b/grafana/dashboards/overview.json @@ -0,0 +1,101 @@ +{ + "uid": "meta-observability-overview", + "title": "Meta Observability Overview", + "schemaVersion": 38, + "version": 1, + "refresh": "30s", + "time": { + "from": "now-6h", + "to": "now" + }, +"panels": [ + { + "id": 1, + "type": "timeseries", + "title": "Node CPU Busy %", + "gridPos": { "x": 0, "y": 0, "w": 12, "h": 8 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "refId": "A", + "expr": "100 * (1 - avg by(instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[5m])))" + } + ] + }, + { + "id": 2, + "type": "timeseries", + "title": "Named Process Groups", + "gridPos": { "x": 12, "y": 0, "w": 12, "h": 8 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "refId": "A", + "expr": "namedprocess_namegroup_num_procs" + } + ] + }, + { + "id": 3, + "type": "timeseries", + "title": "Process Memory RSS (bytes)", + "gridPos": { "x": 0, "y": 8, "w": 12, "h": 8 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "refId": "A", + "expr": "namedprocess_namegroup_memory_bytes{memtype=\"resident\"}", + "legendFormat": "{{groupname}} - RSS" + }, + { + "refId": "B", + "expr": "namedprocess_namegroup_memory_bytes{memtype=\"swapped\"}", + "legendFormat": "{{groupname}} - Swap" + } + ], + "fieldConfig": { + "defaults": { + "unit": "bytes" + } + } + }, + { + "id": 4, + "type": "timeseries", + "title": "Process Disk I/O (bytes/sec)", + "gridPos": { "x": 12, "y": 8, "w": 12, "h": 8 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "refId": "A", + "expr": "rate(namedprocess_namegroup_read_bytes_total[5m])", + "legendFormat": "{{groupname}} - Read" + }, + { + "refId": "B", + "expr": "rate(namedprocess_namegroup_write_bytes_total[5m])", + "legendFormat": "{{groupname}} - Write" + } + ], + "fieldConfig": { + "defaults": { + "unit": "Bps" + } + } + }, + { + "id": 5, + "type": "timeseries", + "title": "Process Thread Count", + "gridPos": { "x": 0, "y": 16, "w": 12, "h": 8 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { + "refId": "A", + "expr": "namedprocess_namegroup_num_threads", + "legendFormat": "{{groupname}}" + } + ] + } + ] +} diff --git a/grafana/provisioning/dashboards/dashboards.yml b/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 00000000..e2f6873c --- /dev/null +++ b/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,11 @@ +apiVersion: 1 + +providers: + - name: Default + orgId: 1 + folder: "" + type: file + disableDeletion: false + editable: true + options: + path: /var/lib/grafana/dashboards diff --git a/grafana/provisioning/datasources/prometheus.yml b/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 00000000..af749282 --- /dev/null +++ b/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,10 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + uid: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: true diff --git a/prometheus/process-exporter.yml b/prometheus/process-exporter.yml index 841e68ac..dd8798af 100644 --- a/prometheus/process-exporter.yml +++ b/prometheus/process-exporter.yml @@ -1,20 +1,26 @@ process_names: - - name: "python" + - name: "server-python" cmdline: - - ".*python.*" + - '.*python(3(\.\d+)?)?.*(run\.py|gunicorn|flask).*' + - '.*uv\s+run\s+run\.py.*' - - name: "node" + - name: "frontend" + comm: + - 'next-server (v' + - 'next-server' + + - name: "database-postgres" cmdline: - - ".*node.*" + - '.*postgres.*' - - name: "nginx" + - name: "otel-collector" cmdline: - - ".*nginx.*" + - '.*otelcol.*' - - name: "postgres" + - name: "prometheus" cmdline: - - ".*postgres.*" + - '.*prometheus.*' - - name: "otelcol" + - name: "grafana" cmdline: - - ".*otelcol.*" + - '.*grafana.*' diff --git a/prometheus/prometheus.yml b/prometheus/prometheus.yml index 6ebc2153..3bdd34bd 100644 --- a/prometheus/prometheus.yml +++ b/prometheus/prometheus.yml @@ -1,18 +1,14 @@ global: - scrape_interval: 15s # By default, scrape targets every 15 seconds. + scrape_interval: 15s scrape_configs: - # Job to scrape Prometheus's own metrics - job_name: 'prometheus' static_configs: - targets: ['localhost:9090'] # Prometheus server default port - # Job to scrape metrics from the OpenTelemetry Collector - job_name: 'otel-collector' static_configs: - # The OTel Collector usually exposes Prometheus metrics at a specific endpoint/port - targets: ['otel:8889'] # Common default for OTel Collector Prometheus exporter - # Example: If OTel endpoint has a specific metrics path, define it here metrics_path: /metrics - job_name: 'node-exporter' From 877d416eadee73f03a5696e98c4ae41175128f66 Mon Sep 17 00:00:00 2001 From: Victor Date: Sat, 4 Apr 2026 15:48:25 -0400 Subject: [PATCH 05/14] promtail and loki --- Dockerfile | 2 +- app/__init__.py | 44 ++++++++++++++++++++++- docker-compose.yml | 31 ++++++++++++++++ grafana/provisioning/datasources/loki.yml | 9 +++++ loki/config.yml | 27 ++++++++++++++ prometheus/process-exporter.yml | 15 ++++++-- promtail/config.yml | 23 ++++++++++++ run.py | 5 ++- 8 files changed, 150 insertions(+), 6 deletions(-) create mode 100644 grafana/provisioning/datasources/loki.yml create mode 100644 loki/config.yml create mode 100644 promtail/config.yml diff --git a/Dockerfile b/Dockerfile index 85bfcb96..9a7d4ccd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,4 +16,4 @@ EXPOSE 5000 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/health/live')" || exit 1 -CMD ["uv", "run", "python", "run.py"] +CMD [".venv/bin/python", "run.py"] diff --git a/app/__init__.py b/app/__init__.py index 5cd4320c..2e310138 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,7 +1,9 @@ import os +import logging +import time from dotenv import load_dotenv -from flask import Flask, jsonify +from flask import Flask, jsonify, g, request from flask_cors import CORS from app.database import init_db, db, check_db_connection @@ -12,12 +14,32 @@ from app.routes import register_routes +def _configure_logging(app: Flask) -> None: + level_name = os.environ.get("LOG_LEVEL", "INFO").upper() + level = getattr(logging, level_name, logging.INFO) + + formatter = logging.Formatter( + "%(asctime)s %(levelname)s [%(name)s] %(message)s" + ) + handler = logging.StreamHandler() + handler.setFormatter(formatter) + + app.logger.handlers.clear() + app.logger.addHandler(handler) + app.logger.setLevel(level) + app.logger.propagate = False + + logging.getLogger("werkzeug").setLevel(level) + app.logger.info("Logger configured", extra={"log_level": level_name}) + + def create_app(): load_dotenv() app = Flask(__name__) app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-change-me") CORS(app) + _configure_logging(app) init_db(app) init_cache() @@ -34,6 +56,23 @@ def create_app(): register_routes(app) + @app.before_request + def _before_request_log_start(): + g.request_start = time.perf_counter() + + @app.after_request + def _after_request_log(response): + start = getattr(g, "request_start", None) + elapsed_ms = (time.perf_counter() - start) * 1000 if start else 0 + app.logger.info( + "%s %s -> %s (%.2f ms)", + request.method, + request.path, + response.status_code, + elapsed_ms, + ) + return response + def _dependency_status(): """Check DB and Redis. Returns (db_status, cache_status).""" try: @@ -82,14 +121,17 @@ def health(): @app.errorhandler(404) def not_found(e): + app.logger.warning("404 %s", request.path) return jsonify(error="not found"), 404 @app.errorhandler(405) def method_not_allowed(e): + app.logger.warning("405 %s %s", request.method, request.path) return jsonify(error="method not allowed"), 405 @app.errorhandler(500) def internal_error(e): + app.logger.exception("500 %s", request.path) return jsonify(error="internal server error"), 500 return app diff --git a/docker-compose.yml b/docker-compose.yml index cc719479..6b8e2456 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,9 @@ services: ports: - "5000:5000" environment: + FLASK_DEBUG: "false" + FLASK_HOST: 0.0.0.0 + FLASK_PORT: "5000" DATABASE_NAME: hackathon_db DATABASE_HOST: db DATABASE_PORT: "5432" @@ -41,6 +44,11 @@ services: condition: service_healthy redis: condition: service_healthy + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/health/live')"] + interval: 15s + timeout: 5s + retries: 5 restart: always frontend: @@ -83,10 +91,31 @@ services: - 8889:8889 - 4318:4318 + loki: + image: grafana/loki:3.0.0 + command: -config.file=/etc/loki/config.yml + ports: + - "3100:3100" + volumes: + - ./loki/config.yml:/etc/loki/config.yml:ro + - loki-data:/loki + + promtail: + image: grafana/promtail:3.0.0 + command: -config.file=/etc/promtail/config.yml + depends_on: + - loki + volumes: + - ./promtail/config.yml:/etc/promtail/config.yml:ro + - /var/run/docker.sock:/var/run/docker.sock + - /var/lib/docker/containers:/var/lib/docker/containers:ro + - promtail-data:/tmp + grafana: image: grafana/grafana:latest depends_on: - prometheus + - loki ports: - "3001:3000" environment: @@ -101,3 +130,5 @@ services: volumes: grafana-data: postgres_data: + loki-data: + promtail-data: diff --git a/grafana/provisioning/datasources/loki.yml b/grafana/provisioning/datasources/loki.yml new file mode 100644 index 00000000..f0e4946f --- /dev/null +++ b/grafana/provisioning/datasources/loki.yml @@ -0,0 +1,9 @@ +apiVersion: 1 + +datasources: + - name: Loki + type: loki + uid: loki + access: proxy + url: http://loki:3100 + editable: true diff --git a/loki/config.yml b/loki/config.yml new file mode 100644 index 00000000..b09e1101 --- /dev/null +++ b/loki/config.yml @@ -0,0 +1,27 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + grpc_listen_port: 9096 + log_level: info + +common: + path_prefix: /loki + replication_factor: 1 + ring: + kvstore: + store: inmemory + +schema_config: + configs: + - from: 2024-01-01 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h + +storage_config: + filesystem: + directory: /loki/chunks diff --git a/prometheus/process-exporter.yml b/prometheus/process-exporter.yml index dd8798af..cad4f485 100644 --- a/prometheus/process-exporter.yml +++ b/prometheus/process-exporter.yml @@ -1,8 +1,9 @@ process_names: - - name: "server-python" + - name: "backend-flask" + comm: + - 'python' cmdline: - - '.*python(3(\.\d+)?)?.*(run\.py|gunicorn|flask).*' - - '.*uv\s+run\s+run\.py.*' + - '.*run\.py.*' - name: "frontend" comm: @@ -24,3 +25,11 @@ process_names: - name: "grafana" cmdline: - '.*grafana.*' + + - name: "loki" + comm: + - 'loki' + + - name: "promtail" + comm: + - 'promtail' diff --git a/promtail/config.yml b/promtail/config.yml new file mode 100644 index 00000000..779ebf43 --- /dev/null +++ b/promtail/config.yml @@ -0,0 +1,23 @@ +server: + http_listen_port: 9080 + grpc_listen_port: 0 + +positions: + filename: /tmp/positions.yaml + +clients: + - url: http://loki:3100/loki/api/v1/push + +scrape_configs: + - job_name: docker + docker_sd_configs: + - host: unix:///var/run/docker.sock + refresh_interval: 5s + relabel_configs: + - source_labels: ['__meta_docker_container_name'] + regex: '/(.*)' + target_label: container + - source_labels: ['__meta_docker_container_label_com_docker_compose_service'] + target_label: service + - target_label: __path__ + replacement: /var/lib/docker/containers/*/*.log diff --git a/run.py b/run.py index 598c2078..4e9872ac 100644 --- a/run.py +++ b/run.py @@ -10,4 +10,7 @@ def test_ui(): return send_from_directory(os.path.dirname(__file__), "test.html") if __name__ == "__main__": - app.run(debug=True) + debug = os.environ.get("FLASK_DEBUG", "false").lower() == "true" + host = os.environ.get("FLASK_HOST", "0.0.0.0") + port = int(os.environ.get("FLASK_PORT", "5000")) + app.run(host=host, port=port, debug=debug) From 6dc613f52ee989be932a27abc93d296845703e11 Mon Sep 17 00:00:00 2001 From: Victor Date: Sat, 4 Apr 2026 16:02:49 -0400 Subject: [PATCH 06/14] basic logs --- app/routes/auth.py | 2 ++ app/routes/links.py | 1 + 2 files changed, 3 insertions(+) diff --git a/app/routes/auth.py b/app/routes/auth.py index b7f35a4d..bd68a1a5 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -46,6 +46,8 @@ def register(): created_at=datetime.utcnow(), ) + current_app.logger.info("Registered user %s", email) + return jsonify( session_token=_make_session_token(user.id), user={"id": user.id, "email": user.email}, diff --git a/app/routes/links.py b/app/routes/links.py index 2843c622..b61e4384 100644 --- a/app/routes/links.py +++ b/app/routes/links.py @@ -197,6 +197,7 @@ def delete_link(code): try: cache.delete(f"url:{code}") except Exception: + pass _log_event(url.id, None, "deleted", {"short_code": code}) From 008dd8459f303ee244ebedf375aacde3f00e3035 Mon Sep 17 00:00:00 2001 From: Victor Date: Sat, 4 Apr 2026 16:33:20 -0400 Subject: [PATCH 07/14] logging --- app/__init__.py | 11 +++++++---- app/routes/auth.py | 2 +- app/routes/events.py | 7 ++++++- app/routes/links.py | 13 ++++++++----- app/routes/redirect.py | 7 ++++--- app/routes/urls.py | 7 +++++-- app/routes/users.py | 12 +++++++++--- 7 files changed, 40 insertions(+), 19 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index 363626b6..bfa2b5d5 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -3,7 +3,7 @@ import time from dotenv import load_dotenv -from flask import Flask, jsonify, g, request +from flask import Flask, current_app, jsonify, g, request from flask_cors import CORS from app.database import init_db, db, check_db_connection @@ -48,6 +48,7 @@ def create_app(): try: db.create_tables([User, URL, Event], safe=True) except Exception: + current_app.logger.warning(f"Error creating tables, they might already exist") pass # Tables already created by another instance db.close() @@ -76,6 +77,7 @@ def _dependency_status(): check_db_connection() db_status = "ok" except Exception as e: + current_app.logger.error(f"Database connection error: {e}") db_status = str(e) from app.cache import get_cache @@ -85,6 +87,7 @@ def _dependency_status(): cache.ping() cache_status = "ok" except Exception as e: + current_app.logger.error(f"Cache connection error: {e}") cache_status = str(e) return db_status, cache_status @@ -118,17 +121,17 @@ def health(): @app.errorhandler(404) def not_found(e): - app.logger.warning("404 %s", request.path) + app.logger.warning(f"404 {request.path}") return jsonify(error="not found"), 404 @app.errorhandler(405) def method_not_allowed(e): - app.logger.warning("405 %s %s", request.method, request.path) + app.logger.warning(f"405 {request.method} {request.path}") return jsonify(error="method not allowed"), 405 @app.errorhandler(500) def internal_error(e): - app.logger.exception("500 %s", request.path) + app.logger.exception(f"500 {request.path}") return jsonify(error="internal server error"), 500 return app diff --git a/app/routes/auth.py b/app/routes/auth.py index bd68a1a5..e6225b02 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -46,7 +46,7 @@ def register(): created_at=datetime.utcnow(), ) - current_app.logger.info("Registered user %s", email) + current_app.logger.info(f"Registered user {email}") return jsonify( session_token=_make_session_token(user.id), diff --git a/app/routes/events.py b/app/routes/events.py index 273ebf46..64981991 100644 --- a/app/routes/events.py +++ b/app/routes/events.py @@ -3,7 +3,7 @@ import os from datetime import datetime -from flask import Blueprint, jsonify, request +from flask import current_app, Blueprint, jsonify, request from app.cache import cache_get, cache_set, cache_delete_pattern from app.database import db @@ -44,12 +44,14 @@ def list_events(): try: query = query.where(Event.url == int(url_id)) except (ValueError, TypeError): + current_app.logger.warning(f"Invalid url_id filter: {url_id}") return jsonify(error="url_id must be an integer"), 400 if user_id is not None: try: query = query.where(Event.user == int(user_id)) except (ValueError, TypeError): + current_app.logger.warning(f"Invalid user_id filter: {user_id}") return jsonify(error="user_id must be an integer"), 400 if event_type is not None: @@ -58,6 +60,7 @@ def list_events(): try: limit = int(request.args.get("limit", 100)) except (ValueError, TypeError): + current_app.logger.warning(f"Invalid limit parameter: {request.args.get('limit')}") limit = 100 query = query.limit(min(limit, 500)) @@ -76,6 +79,7 @@ def load_events_csv(): with open(filepath, newline="", encoding="utf-8") as f: rows = list(csv.DictReader(f)) except FileNotFoundError: + current_app.logger.error(f"File not found: {filepath}") return jsonify(error=f"{filename} not found"), 404 allowed = {"id", "url_id", "user_id", "event_type", "timestamp", "details"} @@ -87,6 +91,7 @@ def load_events_csv(): try: entry["details"] = json.loads(entry["details"]) except (ValueError, TypeError): + current_app.logger.warning(f"Invalid details format in row: {row}") entry["details"] = None entry.setdefault("timestamp", now) cleaned.append(entry) diff --git a/app/routes/links.py b/app/routes/links.py index 814efa18..3776ab39 100644 --- a/app/routes/links.py +++ b/app/routes/links.py @@ -3,7 +3,7 @@ from urllib.parse import urlparse import base62 -from flask import Blueprint, jsonify, request +from flask import current_app, Blueprint, jsonify, request from app.cache import get_cache from app.models.event import Event @@ -21,6 +21,7 @@ def _valid_url(url: str) -> bool: parsed = urlparse(url) return parsed.scheme in ("http", "https") and bool(parsed.netloc) except Exception: + current_app.logger.warning(f"Invalid URL format: {url}") return False @@ -34,7 +35,7 @@ def _log_event(url_id, user_id, event_type, details): details=details, ) except Exception: - pass + current_app.logger.error(f"Error occurred while logging event: {details}") @links_bp.route("/shorten", methods=["POST"]) @@ -92,6 +93,9 @@ def list_links(): page = int(request.args.get("page", 1)) per_page = int(request.args.get("per_page", 20)) except (ValueError, TypeError): + current_app.logger.warning( + f"Invalid page or per_page parameter: {request.args.get('page') or request.args.get('per_page')}" + ) return jsonify(error="page and per_page must be integers"), 400 query = URL.select().where(URL.is_active).order_by(URL.created_at.desc()) @@ -171,7 +175,7 @@ def update_link(code): try: cache.delete(f"url:{code}") except Exception: - pass + current_app.logger.error(f"Error occurred while deleting cache for URL: {code}") _log_event(url.id, None, "updated", {"old_url": old_url, "new_url": url.original_url}) @@ -199,8 +203,7 @@ def delete_link(code): try: cache.delete(f"url:{code}") except Exception: - - pass + current_app.logger.error(f"Error occurred while deleting cache for URL: {code}") _log_event(url.id, None, "deleted", {"short_code": code}) diff --git a/app/routes/redirect.py b/app/routes/redirect.py index e0a7c0e3..0d287766 100644 --- a/app/routes/redirect.py +++ b/app/routes/redirect.py @@ -1,6 +1,6 @@ from datetime import datetime -from flask import Blueprint, jsonify, redirect, request +from flask import Blueprint, current_app, jsonify, redirect, request from app.cache import get_cache from app.models.event import Event @@ -22,6 +22,7 @@ def _log_click(url_id, details): details=details, ) except Exception: + current_app.logger.error(f"Error occurred while logging click event: {details}") pass @@ -48,7 +49,7 @@ def follow(code): _log_click(url.id, details) return redirect(cached_url, code=302) except Exception: - pass + current_app.logger.error(f"Error occurred while fetching cached URL: {code}") url = URL.get_or_none(URL.short_code == code, URL.is_active) if not url: @@ -58,7 +59,7 @@ def follow(code): try: cache.set(f"url:{code}", url.original_url, ex=CACHE_TTL) except Exception: - pass + current_app.logger.error(f"Error occurred while setting cache for URL: {code}") details = { "ip": request.remote_addr, diff --git a/app/routes/urls.py b/app/routes/urls.py index 2f55541e..d72ca4a3 100644 --- a/app/routes/urls.py +++ b/app/routes/urls.py @@ -4,7 +4,7 @@ from datetime import datetime import base62 -from flask import Blueprint, jsonify, request +from flask import Blueprint, current_app, jsonify, request from app.cache import cache_get, cache_set, cache_delete, cache_delete_pattern from app.database import db @@ -27,7 +27,7 @@ def _log_event(url_id, user_id, event_type, details): details=details, ) except Exception: - pass + current_app.logger.error(f"Error occurred while logging event: {details}") def _generate_short_code(length: int = 7) -> str: @@ -63,6 +63,7 @@ def list_urls(): try: query = query.where(URL.user == int(user_id)) except (ValueError, TypeError): + current_app.logger.warning(f"Invalid user_id parameter: {user_id}") return jsonify(error="user_id must be an integer"), 400 if is_active_str is not None: @@ -71,6 +72,7 @@ def list_urls(): try: limit = int(request.args.get("limit", 100)) except (ValueError, TypeError): + current_app.logger.warning(f"Invalid limit parameter: {request.args.get('limit')}") limit = 100 query = query.limit(min(limit, 500)) @@ -89,6 +91,7 @@ def load_urls_csv(): with open(filepath, newline="", encoding="utf-8") as f: rows = list(csv.DictReader(f)) except FileNotFoundError: + current_app.logger.error(f"File not found: {filepath}") return jsonify(error=f"{filename} not found"), 404 allowed = {"id", "user_id", "short_code", "original_url", "title", "is_active", "created_at", "updated_at"} diff --git a/app/routes/users.py b/app/routes/users.py index f7391a55..70f1750c 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -2,7 +2,7 @@ import os from datetime import datetime -from flask import Blueprint, jsonify, request +from flask import Blueprint, current_app, jsonify, request from app.cache import cache_get, cache_set, cache_delete, cache_delete_pattern from app.database import db @@ -10,7 +10,9 @@ from app.models.url import URL from app.models.user import User -_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +_PROJECT_ROOT = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +) users_bp = Blueprint("users", __name__, url_prefix="/users") @@ -31,6 +33,9 @@ def get_users_list(): page = int(request.args.get("page", 1)) per_page = int(request.args.get("per_page", 20)) except (ValueError, TypeError): + current_app.logger.warning( + f"Invalid page or per_page parameter: {request.args.get('page') or request.args.get('per_page')}" + ) return jsonify(error="page and per_page must be integers"), 400 cache_key = f"users:list:{page}:{per_page}" @@ -56,6 +61,7 @@ def load_users_csv(): with open(filepath, newline="", encoding="utf-8") as f: rows = list(csv.DictReader(f)) except FileNotFoundError: + current_app.logger.error(f"File not found: {filepath}") return jsonify(error=f"{filename} not found"), 404 allowed = {"id", "email", "username", "password_hash", "created_at", "updated_at"} @@ -70,7 +76,7 @@ def load_users_csv(): with db.atomic(): for i in range(0, len(cleaned), 100): - User.insert_many(cleaned[i:i + 100]).on_conflict_ignore().execute() + User.insert_many(cleaned[i : i + 100]).on_conflict_ignore().execute() db.execute_sql("SELECT setval('users_id_seq', (SELECT MAX(id) FROM users));") From cebc54e80b209bff2cf25b13f2d528b8bf9c0abb Mon Sep 17 00:00:00 2001 From: Victor Date: Sat, 4 Apr 2026 16:40:55 -0400 Subject: [PATCH 08/14] logging format change --- app/__init__.py | 12 ++++++------ app/routes/auth.py | 2 +- app/routes/events.py | 10 +++++----- app/routes/links.py | 10 ++++------ app/routes/redirect.py | 7 +++---- app/routes/urls.py | 8 ++++---- app/routes/users.py | 5 +++-- 7 files changed, 26 insertions(+), 28 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index bfa2b5d5..201a038d 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -48,7 +48,7 @@ def create_app(): try: db.create_tables([User, URL, Event], safe=True) except Exception: - current_app.logger.warning(f"Error creating tables, they might already exist") + current_app.logger.warning("Error creating tables, they might already exist") pass # Tables already created by another instance db.close() @@ -77,7 +77,7 @@ def _dependency_status(): check_db_connection() db_status = "ok" except Exception as e: - current_app.logger.error(f"Database connection error: {e}") + current_app.logger.error("Database connection error: %s", e) db_status = str(e) from app.cache import get_cache @@ -87,7 +87,7 @@ def _dependency_status(): cache.ping() cache_status = "ok" except Exception as e: - current_app.logger.error(f"Cache connection error: {e}") + current_app.logger.error("Cache connection error: %s", e) cache_status = str(e) return db_status, cache_status @@ -121,17 +121,17 @@ def health(): @app.errorhandler(404) def not_found(e): - app.logger.warning(f"404 {request.path}") + app.logger.warning("404 %s", request.path) return jsonify(error="not found"), 404 @app.errorhandler(405) def method_not_allowed(e): - app.logger.warning(f"405 {request.method} {request.path}") + app.logger.warning("405 %s %s", request.method, request.path) return jsonify(error="method not allowed"), 405 @app.errorhandler(500) def internal_error(e): - app.logger.exception(f"500 {request.path}") + app.logger.exception("500 %s", request.path) return jsonify(error="internal server error"), 500 return app diff --git a/app/routes/auth.py b/app/routes/auth.py index e6225b02..bd68a1a5 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -46,7 +46,7 @@ def register(): created_at=datetime.utcnow(), ) - current_app.logger.info(f"Registered user {email}") + current_app.logger.info("Registered user %s", email) return jsonify( session_token=_make_session_token(user.id), diff --git a/app/routes/events.py b/app/routes/events.py index 64981991..6c7b8849 100644 --- a/app/routes/events.py +++ b/app/routes/events.py @@ -44,14 +44,14 @@ def list_events(): try: query = query.where(Event.url == int(url_id)) except (ValueError, TypeError): - current_app.logger.warning(f"Invalid url_id filter: {url_id}") + current_app.logger.warning("Invalid url_id filter: %s", url_id) return jsonify(error="url_id must be an integer"), 400 if user_id is not None: try: query = query.where(Event.user == int(user_id)) except (ValueError, TypeError): - current_app.logger.warning(f"Invalid user_id filter: {user_id}") + current_app.logger.warning("Invalid user_id filter: %s", user_id) return jsonify(error="user_id must be an integer"), 400 if event_type is not None: @@ -60,7 +60,7 @@ def list_events(): try: limit = int(request.args.get("limit", 100)) except (ValueError, TypeError): - current_app.logger.warning(f"Invalid limit parameter: {request.args.get('limit')}") + current_app.logger.warning("Invalid limit parameter: %s", request.args.get("limit")) limit = 100 query = query.limit(min(limit, 500)) @@ -79,7 +79,7 @@ def load_events_csv(): with open(filepath, newline="", encoding="utf-8") as f: rows = list(csv.DictReader(f)) except FileNotFoundError: - current_app.logger.error(f"File not found: {filepath}") + current_app.logger.error("File not found: %s", filepath) return jsonify(error=f"{filename} not found"), 404 allowed = {"id", "url_id", "user_id", "event_type", "timestamp", "details"} @@ -91,7 +91,7 @@ def load_events_csv(): try: entry["details"] = json.loads(entry["details"]) except (ValueError, TypeError): - current_app.logger.warning(f"Invalid details format in row: {row}") + current_app.logger.warning("Invalid details format in row: %s", row) entry["details"] = None entry.setdefault("timestamp", now) cleaned.append(entry) diff --git a/app/routes/links.py b/app/routes/links.py index 3776ab39..871d1031 100644 --- a/app/routes/links.py +++ b/app/routes/links.py @@ -21,7 +21,7 @@ def _valid_url(url: str) -> bool: parsed = urlparse(url) return parsed.scheme in ("http", "https") and bool(parsed.netloc) except Exception: - current_app.logger.warning(f"Invalid URL format: {url}") + current_app.logger.warning("Invalid URL format: %s", url) return False @@ -35,7 +35,7 @@ def _log_event(url_id, user_id, event_type, details): details=details, ) except Exception: - current_app.logger.error(f"Error occurred while logging event: {details}") + current_app.logger.error("Error occurred while logging event: %s", details) @links_bp.route("/shorten", methods=["POST"]) @@ -93,9 +93,7 @@ def list_links(): page = int(request.args.get("page", 1)) per_page = int(request.args.get("per_page", 20)) except (ValueError, TypeError): - current_app.logger.warning( - f"Invalid page or per_page parameter: {request.args.get('page') or request.args.get('per_page')}" - ) + current_app.logger.warning("Invalid page or per_page parameter: %s", request.args.get("page") or request.args.get("per_page")) return jsonify(error="page and per_page must be integers"), 400 query = URL.select().where(URL.is_active).order_by(URL.created_at.desc()) @@ -203,7 +201,7 @@ def delete_link(code): try: cache.delete(f"url:{code}") except Exception: - current_app.logger.error(f"Error occurred while deleting cache for URL: {code}") + current_app.logger.error("Error occurred while deleting cache for URL: %s", code) _log_event(url.id, None, "deleted", {"short_code": code}) diff --git a/app/routes/redirect.py b/app/routes/redirect.py index 0d287766..1f897bc1 100644 --- a/app/routes/redirect.py +++ b/app/routes/redirect.py @@ -22,7 +22,7 @@ def _log_click(url_id, details): details=details, ) except Exception: - current_app.logger.error(f"Error occurred while logging click event: {details}") + current_app.logger.error("Error occurred while logging click event: %s", details) pass @@ -49,7 +49,7 @@ def follow(code): _log_click(url.id, details) return redirect(cached_url, code=302) except Exception: - current_app.logger.error(f"Error occurred while fetching cached URL: {code}") + current_app.logger.error("Error occurred while fetching cached URL: %s", code) url = URL.get_or_none(URL.short_code == code, URL.is_active) if not url: @@ -58,8 +58,7 @@ def follow(code): if cache: try: cache.set(f"url:{code}", url.original_url, ex=CACHE_TTL) - except Exception: - current_app.logger.error(f"Error occurred while setting cache for URL: {code}") + except Exception: current_app.logger.error("Error occurred while setting cache for URL: %s", code) details = { "ip": request.remote_addr, diff --git a/app/routes/urls.py b/app/routes/urls.py index d72ca4a3..8b7b14b6 100644 --- a/app/routes/urls.py +++ b/app/routes/urls.py @@ -27,7 +27,7 @@ def _log_event(url_id, user_id, event_type, details): details=details, ) except Exception: - current_app.logger.error(f"Error occurred while logging event: {details}") + current_app.logger.error("Error occurred while logging event: %s", details) def _generate_short_code(length: int = 7) -> str: @@ -63,7 +63,7 @@ def list_urls(): try: query = query.where(URL.user == int(user_id)) except (ValueError, TypeError): - current_app.logger.warning(f"Invalid user_id parameter: {user_id}") + current_app.logger.warning("Invalid user_id parameter: %s", user_id) return jsonify(error="user_id must be an integer"), 400 if is_active_str is not None: @@ -72,7 +72,7 @@ def list_urls(): try: limit = int(request.args.get("limit", 100)) except (ValueError, TypeError): - current_app.logger.warning(f"Invalid limit parameter: {request.args.get('limit')}") + current_app.logger.warning("Invalid limit parameter: %s", request.args.get("limit")) limit = 100 query = query.limit(min(limit, 500)) @@ -91,7 +91,7 @@ def load_urls_csv(): with open(filepath, newline="", encoding="utf-8") as f: rows = list(csv.DictReader(f)) except FileNotFoundError: - current_app.logger.error(f"File not found: {filepath}") + current_app.logger.error("File not found: %s", filepath) return jsonify(error=f"{filename} not found"), 404 allowed = {"id", "user_id", "short_code", "original_url", "title", "is_active", "created_at", "updated_at"} diff --git a/app/routes/users.py b/app/routes/users.py index 70f1750c..567fc8d1 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -34,7 +34,8 @@ def get_users_list(): per_page = int(request.args.get("per_page", 20)) except (ValueError, TypeError): current_app.logger.warning( - f"Invalid page or per_page parameter: {request.args.get('page') or request.args.get('per_page')}" + "Invalid page or per_page parameter: %s", + request.args.get("page") or request.args.get("per_page"), ) return jsonify(error="page and per_page must be integers"), 400 @@ -61,7 +62,7 @@ def load_users_csv(): with open(filepath, newline="", encoding="utf-8") as f: rows = list(csv.DictReader(f)) except FileNotFoundError: - current_app.logger.error(f"File not found: {filepath}") + current_app.logger.error("File not found: %s", filepath) return jsonify(error=f"{filename} not found"), 404 allowed = {"id", "email", "username", "password_hash", "created_at", "updated_at"} From a9ad019a58e53f35e5dbba749f07c8855c9ba306 Mon Sep 17 00:00:00 2001 From: Victor Date: Sat, 4 Apr 2026 16:51:03 -0400 Subject: [PATCH 09/14] f string format --- app/routes/events.py | 4 +-- app/routes/links.py | 10 +++---- app/routes/redirect.py | 9 +++--- app/routes/urls.py | 10 +++---- app/routes/users.py | 10 +++---- scripts/create_test_user.py | 6 ++-- scripts/init_db.py | 12 ++++---- tests/test_events.py | 4 +-- tests/test_integration.py | 58 ++++++++++++++++++------------------- tests/test_urls.py | 20 ++++++------- tests/test_users.py | 18 ++++++------ 11 files changed, 81 insertions(+), 80 deletions(-) diff --git a/app/routes/events.py b/app/routes/events.py index 6c7b8849..16c23f02 100644 --- a/app/routes/events.py +++ b/app/routes/events.py @@ -33,7 +33,7 @@ def list_events(): user_id = request.args.get("user_id") event_type = request.args.get("event_type") - cache_key = f"events:list:{url_id}:{user_id}:{event_type}" + cache_key = "events:list:" + str(url_id) + ":" + str(user_id) + ":" + str(event_type) cached = cache_get(cache_key) if cached is not None: return jsonify(cached) @@ -80,7 +80,7 @@ def load_events_csv(): rows = list(csv.DictReader(f)) except FileNotFoundError: current_app.logger.error("File not found: %s", filepath) - return jsonify(error=f"{filename} not found"), 404 + return jsonify(error=filename + " not found"), 404 allowed = {"id", "url_id", "user_id", "event_type", "timestamp", "details"} now = str(datetime.utcnow()) diff --git a/app/routes/links.py b/app/routes/links.py index 871d1031..73c45178 100644 --- a/app/routes/links.py +++ b/app/routes/links.py @@ -55,7 +55,7 @@ def shorten(): if existing: return jsonify( short_code=existing.short_code, - short_url=f"{request.host_url}{existing.short_code}", + short_url=request.host_url + existing.short_code, original_url=existing.original_url, title=existing.title, ) @@ -80,7 +80,7 @@ def shorten(): return jsonify( short_code=short_code, - short_url=f"{request.host_url}{short_code}", + short_url=request.host_url + short_code, original_url=original_url, title=title, ), 201 @@ -171,9 +171,9 @@ def update_link(code): cache = get_cache() if cache: try: - cache.delete(f"url:{code}") + cache.delete("url:" + code) except Exception: - current_app.logger.error(f"Error occurred while deleting cache for URL: {code}") + current_app.logger.error("Error occurred while deleting cache for URL: %s", code) _log_event(url.id, None, "updated", {"old_url": old_url, "new_url": url.original_url}) @@ -199,7 +199,7 @@ def delete_link(code): cache = get_cache() if cache: try: - cache.delete(f"url:{code}") + cache.delete("url:" + code) except Exception: current_app.logger.error("Error occurred while deleting cache for URL: %s", code) diff --git a/app/routes/redirect.py b/app/routes/redirect.py index 1f897bc1..140919fd 100644 --- a/app/routes/redirect.py +++ b/app/routes/redirect.py @@ -35,11 +35,11 @@ def follow(code): cache = get_cache() if cache: try: - cached_url = cache.get(f"url:{code}") + cached_url = cache.get("url:" + code) if cached_url: url = URL.get_or_none(URL.short_code == code) if not url or not url.is_active: - cache.delete(f"url:{code}") + cache.delete("url:" + code) return jsonify(error="Short link not found"), 404 details = { "ip": request.remote_addr, @@ -57,8 +57,9 @@ def follow(code): if cache: try: - cache.set(f"url:{code}", url.original_url, ex=CACHE_TTL) - except Exception: current_app.logger.error("Error occurred while setting cache for URL: %s", code) + cache.set("url:" + code, url.original_url, ex=CACHE_TTL) + except Exception: + current_app.logger.error("Error occurred while setting cache for URL: %s", code) details = { "ip": request.remote_addr, diff --git a/app/routes/urls.py b/app/routes/urls.py index 8b7b14b6..2c9ac194 100644 --- a/app/routes/urls.py +++ b/app/routes/urls.py @@ -52,7 +52,7 @@ def list_urls(): user_id = request.args.get("user_id") is_active_str = request.args.get("is_active") - cache_key = f"urls:list:{user_id}:{is_active_str}" + cache_key = "urls:list:" + str(user_id) + ":" + str(is_active_str) cached = cache_get(cache_key) if cached is not None: return jsonify(cached) @@ -92,7 +92,7 @@ def load_urls_csv(): rows = list(csv.DictReader(f)) except FileNotFoundError: current_app.logger.error("File not found: %s", filepath) - return jsonify(error=f"{filename} not found"), 404 + return jsonify(error=filename + " not found"), 404 allowed = {"id", "user_id", "short_code", "original_url", "title", "is_active", "created_at", "updated_at"} now = str(datetime.utcnow()) @@ -149,7 +149,7 @@ def create_url(): @urls_bp.route("/", methods=["GET"]) def get_url(url_id): - cache_key = f"urls:{url_id}" + cache_key = "urls:" + str(url_id) cached = cache_get(cache_key) if cached is not None: return jsonify(cached) @@ -178,7 +178,7 @@ def update_url(url_id): url.updated_at = datetime.utcnow() url.save() - cache_delete(f"urls:{url_id}") + cache_delete("urls:" + str(url_id)) cache_delete_pattern("urls:list:*") return jsonify(_url_dict(url)) @@ -192,7 +192,7 @@ def delete_url(url_id): # Delete dependent events first (FK constraint) Event.delete().where(Event.url == url_id).execute() url.delete_instance() - cache_delete(f"urls:{url_id}") + cache_delete("urls:" + str(url_id)) cache_delete_pattern("urls:list:*") cache_delete_pattern("events:list:*") return jsonify(message="deleted"), 200 diff --git a/app/routes/users.py b/app/routes/users.py index 567fc8d1..7f1c3435 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -39,7 +39,7 @@ def get_users_list(): ) return jsonify(error="page and per_page must be integers"), 400 - cache_key = f"users:list:{page}:{per_page}" + cache_key = "users:list:" + str(page) + ":" + str(per_page) cached = cache_get(cache_key) if cached is not None: return jsonify(cached) @@ -63,7 +63,7 @@ def load_users_csv(): rows = list(csv.DictReader(f)) except FileNotFoundError: current_app.logger.error("File not found: %s", filepath) - return jsonify(error=f"{filename} not found"), 404 + return jsonify(error=filename + " not found"), 404 allowed = {"id", "email", "username", "password_hash", "created_at", "updated_at"} now = str(datetime.utcnow()) @@ -86,7 +86,7 @@ def load_users_csv(): @users_bp.route("/", methods=["GET"]) def get_user(user_id): - cache_key = f"users:{user_id}" + cache_key = "users:" + str(user_id) cached = cache_get(cache_key) if cached is not None: return jsonify(cached) @@ -137,7 +137,7 @@ def update_user(user_id): user.updated_at = datetime.utcnow() user.save() - cache_delete(f"users:{user_id}") + cache_delete("users:" + str(user_id)) cache_delete_pattern("users:list:*") return jsonify(_user_dict(user)) @@ -152,7 +152,7 @@ def delete_user(user_id): Event.delete().where(Event.user == user_id).execute() URL.delete().where(URL.user == user_id).execute() user.delete_instance() - cache_delete(f"users:{user_id}") + cache_delete("users:" + str(user_id)) cache_delete_pattern("users:list:*") cache_delete_pattern("urls:list:*") cache_delete_pattern("events:list:*") diff --git a/scripts/create_test_user.py b/scripts/create_test_user.py index 15500180..f250fbf7 100644 --- a/scripts/create_test_user.py +++ b/scripts/create_test_user.py @@ -33,7 +33,7 @@ def create_app(): existing = User.get_or_none(User.email == email) if existing: - print(f"Test user already exists: {email}") + print("Test user already exists: " + email) else: User.create( email=email, @@ -41,5 +41,5 @@ def create_app(): ) print("Test user created:") - print(f" Email: {email}") - print(f" Password: {password}") + print(" Email: " + email) + print(" Password: " + password) diff --git a/scripts/init_db.py b/scripts/init_db.py index 8016bf0f..7515ae00 100644 --- a/scripts/init_db.py +++ b/scripts/init_db.py @@ -28,7 +28,7 @@ def create_tables(): def seed_users(filepath="users.csv"): - print(f"Seeding users from {filepath}...") + print("Seeding users from " + filepath + "...") with open(filepath, newline="", encoding="utf-8") as f: rows = list(csv.DictReader(f)) @@ -37,11 +37,11 @@ def seed_users(filepath="users.csv"): batch = rows[batch_start:batch_start + 100] User.insert_many(batch).on_conflict_ignore().execute() - print(f" {len(rows)} users seeded.") + print(" " + str(len(rows)) + " users seeded.") def seed_urls(filepath="urls.csv"): - print(f"Seeding URLs from {filepath}...") + print("Seeding URLs from " + filepath + "...") with open(filepath, newline="", encoding="utf-8") as f: rows = list(csv.DictReader(f)) @@ -55,11 +55,11 @@ def seed_urls(filepath="urls.csv"): batch = rows[batch_start:batch_start + 100] URL.insert_many(batch).on_conflict_ignore().execute() - print(f" {len(rows)} URLs seeded.") + print(" " + str(len(rows)) + " URLs seeded.") def seed_events(filepath="events.csv"): - print(f"Seeding events from {filepath}...") + print("Seeding events from " + filepath + "...") with open(filepath, newline="", encoding="utf-8") as f: rows = list(csv.DictReader(f)) @@ -77,7 +77,7 @@ def seed_events(filepath="events.csv"): batch = rows[batch_start:batch_start + 100] Event.insert_many(batch).on_conflict_ignore().execute() - print(f" {len(rows)} events seeded.") + print(" " + str(len(rows)) + " events seeded.") def reset_sequences(): diff --git a/tests/test_events.py b/tests/test_events.py index a63cca58..fc45d60f 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -38,7 +38,7 @@ def test_get_events_by_url(client): _create_event(client, url_id1) _create_event(client, url_id1) _create_event(client, url_id2) - r = client.get(f"/events?url_id={url_id1}") + r = client.get("/events?url_id=" + str(url_id1)) assert r.status_code == 200 data = r.get_json() assert len(data) == 3 # 1 auto-logged "created" + 2 manually created @@ -51,7 +51,7 @@ def test_get_events_by_user(client): _create_event(client, url_id, user_id=uid) _create_event(client, url_id, user_id=uid) _create_event(client, url_id, user_id=None) - r = client.get(f"/events?user_id={uid}") + r = client.get("/events?user_id=" + str(uid)) assert r.status_code == 200 data = r.get_json() assert len(data) == 2 diff --git a/tests/test_integration.py b/tests/test_integration.py index 349dfed2..9a079c90 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -83,7 +83,7 @@ def test_shorten_deleted_url_gets_new_code(client): r1 = shorten(client, "https://deleted.example.com") code = r1.get_json()["short_code"] - client.delete(f"/api/links/{code}") + client.delete("/api/links/" + code) r2 = shorten(client, "https://deleted.example.com") assert r2.status_code == 201 @@ -94,16 +94,16 @@ def test_shorten_deleted_url_gets_new_code(client): def test_redirect_valid(client): code = shorten(client, "https://redirect.example.com").get_json()["short_code"] - r = client.get(f"/{code}", follow_redirects=False) + r = client.get("/" + code, follow_redirects=False) assert r.status_code == 302 assert r.headers["Location"] == "https://redirect.example.com" def test_redirect_increments_click_count(client): code = shorten(client, "https://clicks.example.com").get_json()["short_code"] - client.get(f"/{code}", follow_redirects=False) - client.get(f"/{code}", follow_redirects=False) - r = client.get(f"/api/links/{code}") + client.get("/" + code, follow_redirects=False) + client.get("/" + code, follow_redirects=False) + r = client.get("/api/links/" + code) assert r.get_json()["click_count"] == 2 @@ -115,8 +115,8 @@ def test_redirect_nonexistent(client): def test_redirect_deleted_link(client): code = shorten(client, "https://todelete.example.com").get_json()["short_code"] - client.delete(f"/api/links/{code}") - r = client.get(f"/{code}", follow_redirects=False) + client.delete("/api/links/" + code) + r = client.get("/" + code, follow_redirects=False) assert r.status_code == 404 @@ -124,7 +124,7 @@ def test_redirect_deleted_link(client): def test_stats_valid(client): code = shorten(client, "https://stats.example.com").get_json()["short_code"] - r = client.get(f"/{code}+") + r = client.get("/" + code + "+") assert r.status_code == 200 data = r.get_json() assert data["short_code"] == code @@ -138,8 +138,8 @@ def test_stats_nonexistent(client): def test_stats_deleted(client): code = shorten(client, "https://statsdel.example.com").get_json()["short_code"] - client.delete(f"/api/links/{code}") - r = client.get(f"/{code}+") + client.delete("/api/links/" + code) + r = client.get("/" + code + "+") assert r.status_code == 404 @@ -156,7 +156,7 @@ def test_list_links_returns_active(client): def test_list_links_excludes_deleted(client): code = shorten(client, "https://listdel.example.com").get_json()["short_code"] - client.delete(f"/api/links/{code}") + client.delete("/api/links/" + code) r = client.get("/api/links") urls = [link["short_code"] for link in r.get_json()["links"]] assert code not in urls @@ -164,7 +164,7 @@ def test_list_links_excludes_deleted(client): def test_list_links_pagination(client): for i in range(5): - shorten(client, f"https://page{i}.example.com") + shorten(client, "https://page" + str(i) + ".example.com") r = client.get("/api/links?page=1&per_page=2") assert r.status_code == 200 assert len(r.get_json()["links"]) <= 2 @@ -179,7 +179,7 @@ def test_list_links_invalid_page(client): def test_link_stats_valid(client): code = shorten(client, "https://detail.example.com").get_json()["short_code"] - r = client.get(f"/api/links/{code}") + r = client.get("/api/links/" + code) assert r.status_code == 200 data = r.get_json() assert data["short_code"] == code @@ -194,8 +194,8 @@ def test_link_stats_nonexistent(client): def test_link_stats_deleted(client): code = shorten(client, "https://detaildel.example.com").get_json()["short_code"] - client.delete(f"/api/links/{code}") - r = client.get(f"/api/links/{code}") + client.delete("/api/links/" + code) + r = client.get("/api/links/" + code) assert r.status_code == 404 @@ -203,21 +203,21 @@ def test_link_stats_deleted(client): def test_update_link(client): code = shorten(client, "https://old.example.com").get_json()["short_code"] - r = client.put(f"/api/links/{code}", json={"url": "https://new.example.com"}) + r = client.put("/api/links/" + code, json={"url": "https://new.example.com"}) assert r.status_code == 200 assert r.get_json()["original_url"] == "https://new.example.com" def test_update_link_reflects_on_redirect(client): code = shorten(client, "https://before.example.com").get_json()["short_code"] - client.put(f"/api/links/{code}", json={"url": "https://after.example.com"}) - r = client.get(f"/{code}", follow_redirects=False) + client.put("/api/links/" + code, json={"url": "https://after.example.com"}) + r = client.get("/" + code, follow_redirects=False) assert r.headers["Location"] == "https://after.example.com" def test_update_link_invalid_url(client): code = shorten(client, "https://updatebad.example.com").get_json()["short_code"] - r = client.put(f"/api/links/{code}", json={"url": "not-a-url"}) + r = client.put("/api/links/" + code, json={"url": "not-a-url"}) assert r.status_code == 400 @@ -228,8 +228,8 @@ def test_update_link_nonexistent(client): def test_update_link_deleted(client): code = shorten(client, "https://updatedel.example.com").get_json()["short_code"] - client.delete(f"/api/links/{code}") - r = client.put(f"/api/links/{code}", json={"url": "https://example.com"}) + client.delete("/api/links/" + code) + r = client.put("/api/links/" + code, json={"url": "https://example.com"}) assert r.status_code == 404 @@ -237,7 +237,7 @@ def test_update_link_deleted(client): def test_delete_link(client): code = shorten(client, "https://todel.example.com").get_json()["short_code"] - r = client.delete(f"/api/links/{code}") + r = client.delete("/api/links/" + code) assert r.status_code == 200 @@ -249,8 +249,8 @@ def test_delete_link_nonexistent(client): def test_delete_link_already_deleted(client): code = shorten(client, "https://deldel.example.com").get_json()["short_code"] - client.delete(f"/api/links/{code}") - r = client.delete(f"/api/links/{code}") + client.delete("/api/links/" + code) + r = client.delete("/api/links/" + code) assert r.status_code == 404 @@ -385,7 +385,7 @@ def test_redirect_cache_get_exception_falls_through_to_db(client): mock_cache = MagicMock() mock_cache.get.side_effect = Exception("redis error") with patch("app.routes.redirect.get_cache", return_value=mock_cache): - r = client.get(f"/{url_obj.short_code}") + r = client.get("/" + url_obj.short_code) assert r.status_code == 302 @@ -396,7 +396,7 @@ def test_redirect_cache_set_exception_still_redirects(client): mock_cache.get.return_value = None mock_cache.set.side_effect = Exception("redis write error") with patch("app.routes.redirect.get_cache", return_value=mock_cache): - r = client.get(f"/{url_obj.short_code}") + r = client.get("/" + url_obj.short_code) assert r.status_code == 302 @@ -432,7 +432,7 @@ def _mock_generate(length=7): def test_update_link_title_only(client): code = shorten(client, "https://example.com/title-only").get_json()["short_code"] - r = client.put(f"/api/links/{code}", json={"title": "New Title Only"}) + r = client.put("/api/links/" + code, json={"title": "New Title Only"}) assert r.status_code == 200 data = r.get_json() assert data["title"] == "New Title Only" @@ -444,7 +444,7 @@ def test_update_cache_delete_exception_still_succeeds(client): mock_cache = MagicMock() mock_cache.delete.side_effect = Exception("redis delete error") with patch("app.routes.links.get_cache", return_value=mock_cache): - r = client.put(f"/api/links/{code}", json={"url": "https://example.com/updated"}) + r = client.put("/api/links/" + code, json={"url": "https://example.com/updated"}) assert r.status_code == 200 @@ -453,5 +453,5 @@ def test_delete_cache_delete_exception_still_succeeds(client): mock_cache = MagicMock() mock_cache.delete.side_effect = Exception("redis delete error") with patch("app.routes.links.get_cache", return_value=mock_cache): - r = client.delete(f"/api/links/{code}") + r = client.delete("/api/links/" + code) assert r.status_code == 200 diff --git a/tests/test_urls.py b/tests/test_urls.py index 11af4a08..8405ff95 100644 --- a/tests/test_urls.py +++ b/tests/test_urls.py @@ -27,7 +27,7 @@ def test_get_urls_by_user(client): _create_url(client, "https://user1.example.com", user_id=uid) _create_url(client, "https://user2.example.com", user_id=uid) _create_url(client, "https://other.example.com", user_id=None) - r = client.get(f"/urls?user_id={uid}") + r = client.get("/urls?user_id=" + str(uid)) assert r.status_code == 200 data = r.get_json() assert len(data) == 2 @@ -37,7 +37,7 @@ def test_get_urls_by_user(client): def test_get_active_urls(client): _create_url(client, "https://active1.example.com") uid = _create_url(client, "https://todeactivate.example.com").get_json()["id"] - client.put(f"/urls/{uid}", json={"is_active": False}) + client.put("/urls/" + str(uid), json={"is_active": False}) r = client.get("/urls?is_active=true") assert r.status_code == 200 data = r.get_json() @@ -46,7 +46,7 @@ def test_get_active_urls(client): def test_get_inactive_urls(client): uid = _create_url(client, "https://inactive.example.com").get_json()["id"] - client.put(f"/urls/{uid}", json={"is_active": False}) + client.put("/urls/" + str(uid), json={"is_active": False}) r = client.get("/urls?is_active=false") assert r.status_code == 200 data = r.get_json() @@ -80,7 +80,7 @@ def test_create_url_generates_unique_short_codes(client): def test_get_url_by_id(client): url_id = _create_url(client, "https://byid.example.com").get_json()["id"] - r = client.get(f"/urls/{url_id}") + r = client.get("/urls/" + str(url_id)) assert r.status_code == 200 assert r.get_json()["id"] == url_id @@ -94,14 +94,14 @@ def test_get_nonexistent_url(client): def test_update_url_title(client): url_id = _create_url(client, "https://updatetitle.example.com", title="Old").get_json()["id"] - r = client.put(f"/urls/{url_id}", json={"title": "Updated Title"}) + r = client.put("/urls/" + str(url_id), json={"title": "Updated Title"}) assert r.status_code == 200 assert r.get_json()["title"] == "Updated Title" def test_deactivate_url(client): url_id = _create_url(client, "https://deactivate.example.com").get_json()["id"] - r = client.put(f"/urls/{url_id}", json={"is_active": False}) + r = client.put("/urls/" + str(url_id), json={"is_active": False}) assert r.status_code == 200 assert r.get_json()["is_active"] is False @@ -115,14 +115,14 @@ def test_update_nonexistent_url(client): def test_delete_url(client): url_id = _create_url(client, "https://delete.example.com").get_json()["id"] - r = client.delete(f"/urls/{url_id}") + r = client.delete("/urls/" + str(url_id)) assert r.status_code == 200 def test_delete_url_removes_from_db(client): url_id = _create_url(client, "https://gone.example.com").get_json()["id"] - client.delete(f"/urls/{url_id}") - assert client.get(f"/urls/{url_id}").status_code == 404 + client.delete("/urls/" + str(url_id)) + assert client.get("/urls/" + str(url_id)).status_code == 404 def test_delete_nonexistent_url(client): @@ -134,6 +134,6 @@ def test_delete_nonexistent_url(client): def test_redirect_via_short_code(client): short_code = _create_url(client, "https://redirect.example.com").get_json()["short_code"] - r = client.get(f"/{short_code}", follow_redirects=False) + r = client.get("/" + short_code, follow_redirects=False) assert r.status_code == 302 assert r.headers["Location"] == "https://redirect.example.com" diff --git a/tests/test_users.py b/tests/test_users.py index 3e453a9c..7570f866 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -21,7 +21,7 @@ def test_get_users_list_returns_created(client): def test_get_users_pagination(client): for i in range(15): - _create_user(client, f"page{i}@example.com", f"pageuser{i}") + _create_user(client, "page" + str(i) + "@example.com", "pageuser" + str(i)) r = client.get("/users?page=1&per_page=10") assert r.status_code == 200 assert len(r.get_json()) == 10 @@ -29,7 +29,7 @@ def test_get_users_pagination(client): def test_get_users_pagination_page2(client): for i in range(15): - _create_user(client, f"pg2_{i}@example.com", f"pg2user{i}") + _create_user(client, "pg2_" + str(i) + "@example.com", "pg2user" + str(i)) r = client.get("/users?page=2&per_page=10") assert r.status_code == 200 assert len(r.get_json()) == 5 @@ -40,7 +40,7 @@ def test_get_users_pagination_page2(client): def test_get_user_by_id(client): r = _create_user(client, "byid@example.com", "byiduser") user_id = r.get_json()["id"] - r2 = client.get(f"/users/{user_id}") + r2 = client.get("/users/" + str(user_id)) assert r2.status_code == 200 assert r2.get_json()["id"] == user_id @@ -48,7 +48,7 @@ def test_get_user_by_id(client): def test_get_user_by_id_returns_fields(client): r = _create_user(client, "fields@example.com", "fieldsuser") uid = r.get_json()["id"] - data = client.get(f"/users/{uid}").get_json() + data = client.get("/users/" + str(uid)).get_json() assert data["email"] == "fields@example.com" assert data["username"] == "fieldsuser" @@ -85,14 +85,14 @@ def test_create_user_duplicate_email(client): def test_update_user(client): uid = _create_user(client, "upd@example.com", "oldname").get_json()["id"] - r = client.put(f"/users/{uid}", json={"username": "newname"}) + r = client.put("/users/" + str(uid), json={"username": "newname"}) assert r.status_code == 200 assert r.get_json()["username"] == "newname" def test_update_user_email(client): uid = _create_user(client, "oldemail@example.com", "emailuser").get_json()["id"] - r = client.put(f"/users/{uid}", json={"email": "newemail@example.com"}) + r = client.put("/users/" + str(uid), json={"email": "newemail@example.com"}) assert r.status_code == 200 assert r.get_json()["email"] == "newemail@example.com" @@ -106,14 +106,14 @@ def test_update_nonexistent_user(client): def test_delete_user(client): uid = _create_user(client, "del@example.com", "deluser").get_json()["id"] - r = client.delete(f"/users/{uid}") + r = client.delete("/users/" + str(uid)) assert r.status_code == 200 def test_delete_user_removes_from_db(client): uid = _create_user(client, "gone@example.com", "goneuser").get_json()["id"] - client.delete(f"/users/{uid}") - assert client.get(f"/users/{uid}").status_code == 404 + client.delete("/users/" + str(uid)) + assert client.get("/users/" + str(uid)).status_code == 404 def test_delete_nonexistent_user(client): From d13ec9d67c8653f4993b1407417367fb125ab422 Mon Sep 17 00:00:00 2001 From: Victor Date: Sat, 4 Apr 2026 18:28:10 -0400 Subject: [PATCH 10/14] structured logs --- app/__init__.py | 107 +++++++++++++++++++++++++++++++++++------ app/routes/auth.py | 5 +- app/routes/events.py | 25 ++++++++-- app/routes/links.py | 29 +++++++++-- app/routes/redirect.py | 25 ++++++++-- app/routes/urls.py | 20 ++++++-- app/routes/users.py | 13 +++-- 7 files changed, 188 insertions(+), 36 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index 201a038d..fc189ee5 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,6 +1,9 @@ import os +import json +import uuid import logging import time +from datetime import datetime, timezone from dotenv import load_dotenv from flask import Flask, current_app, jsonify, g, request @@ -14,13 +17,45 @@ from app.routes import register_routes +class JsonFormatter(logging.Formatter): + def format(self, record): + payload = { + "ts": datetime.now(timezone.utc).isoformat(), + "level": record.levelname, + "logger": record.name, + "event": record.getMessage(), + "service": "url-shortener-api", + } + + # Allow route and dependency logs to attach machine-readable fields. + for key in ( + "request_id", + "method", + "path", + "status_code", + "duration_ms", + "component", + "error", + "user_id", + "url_id", + "short_code", + "param", + "value", + "resource", + "reason", + "log_level", + ): + if hasattr(record, key): + payload[key] = getattr(record, key) + + return json.dumps(payload, ensure_ascii=True) + + def _configure_logging(app: Flask) -> None: level_name = os.environ.get("LOG_LEVEL", "INFO").upper() level = getattr(logging, level_name, logging.INFO) - formatter = logging.Formatter( - "%(asctime)s %(levelname)s [%(name)s] %(message)s" - ) + formatter = JsonFormatter() handler = logging.StreamHandler() handler.setFormatter(formatter) @@ -29,7 +64,12 @@ def _configure_logging(app: Flask) -> None: app.logger.setLevel(level) app.logger.propagate = False - logging.getLogger("werkzeug").setLevel(level) + werkzeug_logger = logging.getLogger("werkzeug") + werkzeug_logger.handlers.clear() + werkzeug_logger.addHandler(handler) + werkzeug_logger.setLevel(level) + werkzeug_logger.propagate = False + app.logger.info("Logger configured", extra={"log_level": level_name}) @@ -48,7 +88,10 @@ def create_app(): try: db.create_tables([User, URL, Event], safe=True) except Exception: - current_app.logger.warning("Error creating tables, they might already exist") + current_app.logger.warning( + "db_create_tables_skipped", + extra={"component": "db", "reason": "tables_already_exist_or_race"}, + ) pass # Tables already created by another instance db.close() @@ -57,17 +100,21 @@ def create_app(): @app.before_request def _before_request_log_start(): g.request_start = time.perf_counter() + g.request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4()) @app.after_request def _after_request_log(response): start = getattr(g, "request_start", None) elapsed_ms = (time.perf_counter() - start) * 1000 if start else 0 app.logger.info( - "%s %s -> %s (%.2f ms)", - request.method, - request.path, - response.status_code, - elapsed_ms, + "request_completed", + extra={ + "request_id": getattr(g, "request_id", ""), + "method": request.method, + "path": request.path, + "status_code": response.status_code, + "duration_ms": round(elapsed_ms, 2), + }, ) return response @@ -77,7 +124,10 @@ def _dependency_status(): check_db_connection() db_status = "ok" except Exception as e: - current_app.logger.error("Database connection error: %s", e) + current_app.logger.error( + "dependency_check_failed", + extra={"component": "db", "error": str(e)}, + ) db_status = str(e) from app.cache import get_cache @@ -87,7 +137,10 @@ def _dependency_status(): cache.ping() cache_status = "ok" except Exception as e: - current_app.logger.error("Cache connection error: %s", e) + current_app.logger.error( + "dependency_check_failed", + extra={"component": "cache", "error": str(e)}, + ) cache_status = str(e) return db_status, cache_status @@ -121,17 +174,41 @@ def health(): @app.errorhandler(404) def not_found(e): - app.logger.warning("404 %s", request.path) + app.logger.warning( + "http_not_found", + extra={ + "request_id": getattr(g, "request_id", ""), + "method": request.method, + "path": request.path, + "status_code": 404, + }, + ) return jsonify(error="not found"), 404 @app.errorhandler(405) def method_not_allowed(e): - app.logger.warning("405 %s %s", request.method, request.path) + app.logger.warning( + "http_method_not_allowed", + extra={ + "request_id": getattr(g, "request_id", ""), + "method": request.method, + "path": request.path, + "status_code": 405, + }, + ) return jsonify(error="method not allowed"), 405 @app.errorhandler(500) def internal_error(e): - app.logger.exception("500 %s", request.path) + app.logger.exception( + "http_internal_error", + extra={ + "request_id": getattr(g, "request_id", ""), + "method": request.method, + "path": request.path, + "status_code": 500, + }, + ) return jsonify(error="internal server error"), 500 return app diff --git a/app/routes/auth.py b/app/routes/auth.py index bd68a1a5..246dcd19 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -46,7 +46,10 @@ def register(): created_at=datetime.utcnow(), ) - current_app.logger.info("Registered user %s", email) + current_app.logger.info( + "user_registered", + extra={"component": "auth", "user_id": user.id, "value": email}, + ) return jsonify( session_token=_make_session_token(user.id), diff --git a/app/routes/events.py b/app/routes/events.py index a4a123cf..3f859eca 100644 --- a/app/routes/events.py +++ b/app/routes/events.py @@ -44,14 +44,20 @@ def list_events(): try: query = query.where(Event.url == int(url_id)) except (ValueError, TypeError): - current_app.logger.warning("Invalid url_id filter: %s", url_id) + current_app.logger.warning( + "invalid_filter", + extra={"component": "events", "param": "url_id", "value": str(url_id)}, + ) return jsonify(error="url_id must be an integer"), 400 if user_id is not None: try: query = query.where(Event.user == int(user_id)) except (ValueError, TypeError): - current_app.logger.warning("Invalid user_id filter: %s", user_id) + current_app.logger.warning( + "invalid_filter", + extra={"component": "events", "param": "user_id", "value": str(user_id)}, + ) return jsonify(error="user_id must be an integer"), 400 if event_type is not None: @@ -60,7 +66,10 @@ def list_events(): try: limit = int(request.args.get("limit", 100)) except (ValueError, TypeError): - current_app.logger.warning("Invalid limit parameter: %s", request.args.get("limit")) + current_app.logger.warning( + "invalid_limit_parameter", + extra={"component": "events", "param": "limit", "value": str(request.args.get("limit"))}, + ) limit = 100 query = query.limit(min(limit, 500)) @@ -79,7 +88,10 @@ def load_events_csv(): with open(filepath, newline="", encoding="utf-8") as f: rows = list(csv.DictReader(f)) except FileNotFoundError: - current_app.logger.error("File not found: %s", filepath) + current_app.logger.error( + "file_not_found", + extra={"component": "events", "resource": filepath}, + ) return jsonify(error=filename + " not found"), 404 allowed = {"id", "url_id", "user_id", "event_type", "timestamp", "details"} @@ -91,7 +103,10 @@ def load_events_csv(): try: entry["details"] = json.loads(entry["details"]) except (ValueError, TypeError): - current_app.logger.warning("Invalid details format in row: %s", row) + current_app.logger.warning( + "invalid_event_details_format", + extra={"component": "events", "value": str(row)}, + ) entry["details"] = None entry.setdefault("timestamp", now) cleaned.append(entry) diff --git a/app/routes/links.py b/app/routes/links.py index 11b75b84..8c0df665 100644 --- a/app/routes/links.py +++ b/app/routes/links.py @@ -21,7 +21,10 @@ def _valid_url(url: str) -> bool: parsed = urlparse(url) return parsed.scheme in ("http", "https") and bool(parsed.netloc) except Exception: - current_app.logger.warning("Invalid URL format: %s", url) + current_app.logger.warning( + "invalid_url_format", + extra={"component": "links", "param": "url", "value": url}, + ) return False @@ -36,7 +39,10 @@ def _log_event(url_id, user_id, event_type, details): ) cache_delete_pattern("events:list:*") except Exception: - current_app.logger.error("Error occurred while logging event: %s", details) + current_app.logger.error( + "event_logging_failed", + extra={"component": "links", "value": str(details)}, + ) @links_bp.route("/shorten", methods=["POST"]) @@ -94,7 +100,14 @@ def list_links(): page = int(request.args.get("page", 1)) per_page = int(request.args.get("per_page", 20)) except (ValueError, TypeError): - current_app.logger.warning("Invalid page or per_page parameter: %s", request.args.get("page") or request.args.get("per_page")) + current_app.logger.warning( + "invalid_pagination_parameters", + extra={ + "component": "links", + "param": "page_or_per_page", + "value": request.args.get("page") or request.args.get("per_page"), + }, + ) return jsonify(error="page and per_page must be integers"), 400 query = URL.select().where(URL.is_active).order_by(URL.created_at.desc()) @@ -174,7 +187,10 @@ def update_link(code): try: cache.delete("url:" + code) except Exception: - current_app.logger.error("Error occurred while deleting cache for URL: %s", code) + current_app.logger.error( + "cache_delete_failed", + extra={"component": "cache", "short_code": code}, + ) _log_event(url.id, None, "updated", {"old_url": old_url, "new_url": url.original_url}) @@ -202,7 +218,10 @@ def delete_link(code): try: cache.delete("url:" + code) except Exception: - current_app.logger.error("Error occurred while deleting cache for URL: %s", code) + current_app.logger.error( + "cache_delete_failed", + extra={"component": "cache", "short_code": code}, + ) _log_event(url.id, None, "deleted", {"short_code": code}) diff --git a/app/routes/redirect.py b/app/routes/redirect.py index cc8492d1..9626f044 100644 --- a/app/routes/redirect.py +++ b/app/routes/redirect.py @@ -23,7 +23,10 @@ def _log_click(url_id, details): ) cache_delete_pattern("events:list:*") except Exception: - current_app.logger.error("Error occurred while logging click event: %s", details) + current_app.logger.error( + "click_event_logging_failed", + extra={"component": "redirect", "value": str(details)}, + ) pass @@ -38,9 +41,22 @@ def follow(code): try: cached_url = cache.get("url:" + code) if cached_url: + url = URL.get_or_none(URL.short_code == code) + if not url or not url.is_active: + cache.delete(f"url:{code}") + return jsonify(error="Short link not found"), 404 + details = { + "ip": request.remote_addr, + "user_agent": request.headers.get("User-Agent", ""), + "referer": request.headers.get("Referer", ""), + } + _log_click(url.id, details) return redirect(cached_url, code=302) except Exception: - current_app.logger.error("Error occurred while fetching cached URL: %s", code) + current_app.logger.error( + "cache_fetch_failed", + extra={"component": "cache", "short_code": code}, + ) url = URL.get_or_none(URL.short_code == code, URL.is_active) if not url: @@ -50,7 +66,10 @@ def follow(code): try: cache.set("url:" + code, url.original_url, ex=CACHE_TTL) except Exception: - current_app.logger.error("Error occurred while setting cache for URL: %s", code) + current_app.logger.error( + "cache_set_failed", + extra={"component": "cache", "short_code": code}, + ) details = { "ip": request.remote_addr, diff --git a/app/routes/urls.py b/app/routes/urls.py index 2e011a7c..f6b8b9fd 100644 --- a/app/routes/urls.py +++ b/app/routes/urls.py @@ -28,7 +28,10 @@ def _log_event(url_id, user_id, event_type, details): ) cache_delete_pattern("events:list:*") except Exception: - current_app.logger.error("Error occurred while logging event: %s", details) + current_app.logger.error( + "event_logging_failed", + extra={"component": "urls", "value": str(details)}, + ) def _generate_short_code(length: int = 7) -> str: @@ -64,7 +67,10 @@ def list_urls(): try: query = query.where(URL.user == int(user_id)) except (ValueError, TypeError): - current_app.logger.warning("Invalid user_id parameter: %s", user_id) + current_app.logger.warning( + "invalid_user_id_parameter", + extra={"component": "urls", "param": "user_id", "value": str(user_id)}, + ) return jsonify(error="user_id must be an integer"), 400 if is_active_str is not None: @@ -73,7 +79,10 @@ def list_urls(): try: limit = int(request.args.get("limit", 100)) except (ValueError, TypeError): - current_app.logger.warning("Invalid limit parameter: %s", request.args.get("limit")) + current_app.logger.warning( + "invalid_limit_parameter", + extra={"component": "urls", "param": "limit", "value": str(request.args.get("limit"))}, + ) limit = 100 query = query.limit(min(limit, 500)) @@ -92,7 +101,10 @@ def load_urls_csv(): with open(filepath, newline="", encoding="utf-8") as f: rows = list(csv.DictReader(f)) except FileNotFoundError: - current_app.logger.error("File not found: %s", filepath) + current_app.logger.error( + "file_not_found", + extra={"component": "urls", "resource": filepath}, + ) return jsonify(error=filename + " not found"), 404 allowed = {"id", "user_id", "short_code", "original_url", "title", "is_active", "created_at", "updated_at"} diff --git a/app/routes/users.py b/app/routes/users.py index 6a0a8cb8..ba66a327 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -34,8 +34,12 @@ def get_users_list(): per_page = int(request.args.get("per_page", 20)) except (ValueError, TypeError): current_app.logger.warning( - "Invalid page or per_page parameter: %s", - request.args.get("page") or request.args.get("per_page"), + "invalid_pagination_parameters", + extra={ + "component": "users", + "param": "page_or_per_page", + "value": request.args.get("page") or request.args.get("per_page"), + }, ) return jsonify(error="page and per_page must be integers"), 400 @@ -62,7 +66,10 @@ def load_users_csv(): with open(filepath, newline="", encoding="utf-8") as f: rows = list(csv.DictReader(f)) except FileNotFoundError: - current_app.logger.error("File not found: %s", filepath) + current_app.logger.error( + "file_not_found", + extra={"component": "users", "resource": filepath}, + ) return jsonify(error=filename + " not found"), 404 allowed = {"id", "email", "username", "password_hash", "created_at", "updated_at"} From 7d0d6ea441c4933d20ca7acff07ac469ca7fbf70 Mon Sep 17 00:00:00 2001 From: Victor Date: Sat, 4 Apr 2026 18:39:27 -0400 Subject: [PATCH 11/14] structured logs and local logs in memory --- .gitignore | 5 +++++ app/__init__.py | 32 +++++++++++++++++++++++++++----- docker-compose.tier2.yml | 6 ++++++ docker-compose.yml | 3 +++ 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 004b3177..9ff5bac5 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,8 @@ judging/ plan.md +# Local runtime logs +logs/ +!logs/.gitkeep +!logs/**/.gitkeep + diff --git a/app/__init__.py b/app/__init__.py index fc189ee5..e8dcacfa 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -3,6 +3,7 @@ import uuid import logging import time +from logging.handlers import RotatingFileHandler from datetime import datetime, timezone from dotenv import load_dotenv @@ -54,23 +55,44 @@ def format(self, record): def _configure_logging(app: Flask) -> None: level_name = os.environ.get("LOG_LEVEL", "INFO").upper() level = getattr(logging, level_name, logging.INFO) + log_file_path = os.environ.get("LOG_FILE_PATH", "/app/logs/app.log") + log_max_bytes = int(os.environ.get("LOG_FILE_MAX_BYTES", str(10 * 1024 * 1024))) + log_backup_count = int(os.environ.get("LOG_FILE_BACKUP_COUNT", "5")) formatter = JsonFormatter() - handler = logging.StreamHandler() - handler.setFormatter(formatter) + stream_handler = logging.StreamHandler() + stream_handler.setFormatter(formatter) + + log_dir = os.path.dirname(log_file_path) + if log_dir: + os.makedirs(log_dir, exist_ok=True) + file_handler = RotatingFileHandler( + log_file_path, + maxBytes=log_max_bytes, + backupCount=log_backup_count, + encoding="utf-8", + ) + file_handler.setFormatter(formatter) + + handlers = [stream_handler, file_handler] app.logger.handlers.clear() - app.logger.addHandler(handler) + for handler in handlers: + app.logger.addHandler(handler) app.logger.setLevel(level) app.logger.propagate = False werkzeug_logger = logging.getLogger("werkzeug") werkzeug_logger.handlers.clear() - werkzeug_logger.addHandler(handler) + for handler in handlers: + werkzeug_logger.addHandler(handler) werkzeug_logger.setLevel(level) werkzeug_logger.propagate = False - app.logger.info("Logger configured", extra={"log_level": level_name}) + app.logger.info( + "logger_configured", + extra={"log_level": level_name, "resource": log_file_path}, + ) def create_app(): diff --git a/docker-compose.tier2.yml b/docker-compose.tier2.yml index 2e7af54c..95259611 100644 --- a/docker-compose.tier2.yml +++ b/docker-compose.tier2.yml @@ -33,6 +33,9 @@ services: DATABASE_PASSWORD: postgres REDIS_URL: redis://redis:6379 SECRET_KEY: random_secret_key + LOG_FILE_PATH: ${LOG_FILE_PATH_APP_1:-/app/logs/app-1.log} + volumes: + - ./logs/app-1:/app/logs depends_on: db: condition: service_healthy @@ -56,6 +59,9 @@ services: DATABASE_PASSWORD: postgres REDIS_URL: redis://redis:6379 SECRET_KEY: random_secret_key + LOG_FILE_PATH: ${LOG_FILE_PATH_APP_2:-/app/logs/app-2.log} + volumes: + - ./logs/app-2:/app/logs depends_on: db: condition: service_healthy diff --git a/docker-compose.yml b/docker-compose.yml index 6b8e2456..83c26a5b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,6 +39,9 @@ services: DATABASE_PASSWORD: postgres REDIS_URL: redis://redis:6379 SECRET_KEY: random_secret_key + LOG_FILE_PATH: ${LOG_FILE_PATH:-/app/logs/app.log} + volumes: + - ./logs/app:/app/logs depends_on: db: condition: service_healthy From 21115e0280477bf5ba24ba9545ea535a1d3a2219 Mon Sep 17 00:00:00 2001 From: Victor Date: Sat, 4 Apr 2026 18:45:10 -0400 Subject: [PATCH 12/14] endpoint in structued logs --- app/__init__.py | 28 ++++++++++++++++++++++++---- app/routes/auth.py | 2 +- app/routes/events.py | 10 +++++----- app/routes/links.py | 9 +++++---- app/routes/redirect.py | 6 +++--- app/routes/urls.py | 8 ++++---- app/routes/users.py | 3 ++- 7 files changed, 44 insertions(+), 22 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index e8dcacfa..cf3e30f0 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -33,6 +33,8 @@ def format(self, record): "request_id", "method", "path", + "endpoint", + "route", "status_code", "duration_ms", "component", @@ -91,7 +93,7 @@ def _configure_logging(app: Flask) -> None: app.logger.info( "logger_configured", - extra={"log_level": level_name, "resource": log_file_path}, + extra={"endpoint": "app._configure_logging", "log_level": level_name, "resource": log_file_path}, ) @@ -112,7 +114,7 @@ def create_app(): except Exception: current_app.logger.warning( "db_create_tables_skipped", - extra={"component": "db", "reason": "tables_already_exist_or_race"}, + extra={"component": "db", "endpoint": "app.create_app", "reason": "tables_already_exist_or_race"}, ) pass # Tables already created by another instance db.close() @@ -134,6 +136,8 @@ def _after_request_log(response): "request_id": getattr(g, "request_id", ""), "method": request.method, "path": request.path, + "endpoint": request.endpoint or "unknown", + "route": request.url_rule.rule if request.url_rule else "unknown", "status_code": response.status_code, "duration_ms": round(elapsed_ms, 2), }, @@ -148,7 +152,12 @@ def _dependency_status(): except Exception as e: current_app.logger.error( "dependency_check_failed", - extra={"component": "db", "error": str(e)}, + extra={ + "component": "db", + "endpoint": request.endpoint or "unknown", + "route": request.url_rule.rule if request.url_rule else "unknown", + "error": str(e), + }, ) db_status = str(e) @@ -161,7 +170,12 @@ def _dependency_status(): except Exception as e: current_app.logger.error( "dependency_check_failed", - extra={"component": "cache", "error": str(e)}, + extra={ + "component": "cache", + "endpoint": request.endpoint or "unknown", + "route": request.url_rule.rule if request.url_rule else "unknown", + "error": str(e), + }, ) cache_status = str(e) @@ -202,6 +216,8 @@ def not_found(e): "request_id": getattr(g, "request_id", ""), "method": request.method, "path": request.path, + "endpoint": request.endpoint or "unknown", + "route": request.url_rule.rule if request.url_rule else "unknown", "status_code": 404, }, ) @@ -215,6 +231,8 @@ def method_not_allowed(e): "request_id": getattr(g, "request_id", ""), "method": request.method, "path": request.path, + "endpoint": request.endpoint or "unknown", + "route": request.url_rule.rule if request.url_rule else "unknown", "status_code": 405, }, ) @@ -228,6 +246,8 @@ def internal_error(e): "request_id": getattr(g, "request_id", ""), "method": request.method, "path": request.path, + "endpoint": request.endpoint or "unknown", + "route": request.url_rule.rule if request.url_rule else "unknown", "status_code": 500, }, ) diff --git a/app/routes/auth.py b/app/routes/auth.py index 246dcd19..be5f3ab4 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -48,7 +48,7 @@ def register(): current_app.logger.info( "user_registered", - extra={"component": "auth", "user_id": user.id, "value": email}, + extra={"component": "auth", "endpoint": "auth.register", "user_id": user.id, "value": email}, ) return jsonify( diff --git a/app/routes/events.py b/app/routes/events.py index 25067908..58bf343d 100644 --- a/app/routes/events.py +++ b/app/routes/events.py @@ -46,7 +46,7 @@ def list_events(): except (ValueError, TypeError): current_app.logger.warning( "invalid_filter", - extra={"component": "events", "param": "url_id", "value": str(url_id)}, + extra={"component": "events", "endpoint": "events.list_events", "param": "url_id", "value": str(url_id)}, ) return jsonify(error="url_id must be an integer"), 400 @@ -56,7 +56,7 @@ def list_events(): except (ValueError, TypeError): current_app.logger.warning( "invalid_filter", - extra={"component": "events", "param": "user_id", "value": str(user_id)}, + extra={"component": "events", "endpoint": "events.list_events", "param": "user_id", "value": str(user_id)}, ) return jsonify(error="user_id must be an integer"), 400 @@ -68,7 +68,7 @@ def list_events(): except (ValueError, TypeError): current_app.logger.warning( "invalid_limit_parameter", - extra={"component": "events", "param": "limit", "value": str(request.args.get("limit"))}, + extra={"component": "events", "endpoint": "events.list_events", "param": "limit", "value": str(request.args.get("limit"))}, ) limit = 100 query = query.limit(min(limit, 500)) @@ -90,7 +90,7 @@ def load_events_csv(): except FileNotFoundError: current_app.logger.error( "file_not_found", - extra={"component": "events", "resource": filepath}, + extra={"component": "events", "endpoint": "events.load_events_csv", "resource": filepath}, ) return jsonify(error=filename + " not found"), 404 @@ -105,7 +105,7 @@ def load_events_csv(): except (ValueError, TypeError): current_app.logger.warning( "invalid_event_details_format", - extra={"component": "events", "value": str(row)}, + extra={"component": "events", "endpoint": "events.load_events_csv", "value": str(row)}, ) entry["details"] = None entry.setdefault("timestamp", now) diff --git a/app/routes/links.py b/app/routes/links.py index 8c0df665..3d4a1202 100644 --- a/app/routes/links.py +++ b/app/routes/links.py @@ -23,7 +23,7 @@ def _valid_url(url: str) -> bool: except Exception: current_app.logger.warning( "invalid_url_format", - extra={"component": "links", "param": "url", "value": url}, + extra={"component": "links", "endpoint": "links._valid_url", "param": "url", "value": url}, ) return False @@ -41,7 +41,7 @@ def _log_event(url_id, user_id, event_type, details): except Exception: current_app.logger.error( "event_logging_failed", - extra={"component": "links", "value": str(details)}, + extra={"component": "links", "endpoint": "links._log_event", "value": str(details)}, ) @@ -104,6 +104,7 @@ def list_links(): "invalid_pagination_parameters", extra={ "component": "links", + "endpoint": "links.list_links", "param": "page_or_per_page", "value": request.args.get("page") or request.args.get("per_page"), }, @@ -189,7 +190,7 @@ def update_link(code): except Exception: current_app.logger.error( "cache_delete_failed", - extra={"component": "cache", "short_code": code}, + extra={"component": "cache", "endpoint": "links.update_link", "short_code": code}, ) _log_event(url.id, None, "updated", {"old_url": old_url, "new_url": url.original_url}) @@ -220,7 +221,7 @@ def delete_link(code): except Exception: current_app.logger.error( "cache_delete_failed", - extra={"component": "cache", "short_code": code}, + extra={"component": "cache", "endpoint": "links.delete_link", "short_code": code}, ) _log_event(url.id, None, "deleted", {"short_code": code}) diff --git a/app/routes/redirect.py b/app/routes/redirect.py index 96b5229c..76e75879 100644 --- a/app/routes/redirect.py +++ b/app/routes/redirect.py @@ -26,7 +26,7 @@ def _log_click(url_id, details): except Exception: current_app.logger.error( "click_event_logging_failed", - extra={"component": "redirect", "value": str(details)}, + extra={"component": "redirect", "endpoint": "redirect._log_click", "value": str(details)}, ) pass @@ -57,7 +57,7 @@ def follow(code): except Exception: current_app.logger.error( "cache_fetch_failed", - extra={"component": "cache", "short_code": code}, + extra={"component": "cache", "endpoint": "redirect.follow", "short_code": code}, ) url = URL.get_or_none(URL.short_code == code, URL.is_active) @@ -70,7 +70,7 @@ def follow(code): except Exception: current_app.logger.error( "cache_set_failed", - extra={"component": "cache", "short_code": code}, + extra={"component": "cache", "endpoint": "redirect.follow", "short_code": code}, ) _log_click(url.id, details) diff --git a/app/routes/urls.py b/app/routes/urls.py index 3cd85a21..93a92bc6 100644 --- a/app/routes/urls.py +++ b/app/routes/urls.py @@ -31,7 +31,7 @@ def _log_event(url_id, user_id, event_type, details): except Exception: current_app.logger.error( "event_logging_failed", - extra={"component": "urls", "value": str(details)}, + extra={"component": "urls", "endpoint": "urls._log_event", "value": str(details)}, ) @@ -70,7 +70,7 @@ def list_urls(): except (ValueError, TypeError): current_app.logger.warning( "invalid_user_id_parameter", - extra={"component": "urls", "param": "user_id", "value": str(user_id)}, + extra={"component": "urls", "endpoint": "urls.list_urls", "param": "user_id", "value": str(user_id)}, ) return jsonify(error="user_id must be an integer"), 400 @@ -82,7 +82,7 @@ def list_urls(): except (ValueError, TypeError): current_app.logger.warning( "invalid_limit_parameter", - extra={"component": "urls", "param": "limit", "value": str(request.args.get("limit"))}, + extra={"component": "urls", "endpoint": "urls.list_urls", "param": "limit", "value": str(request.args.get("limit"))}, ) limit = 100 query = query.limit(min(limit, 500)) @@ -104,7 +104,7 @@ def load_urls_csv(): except FileNotFoundError: current_app.logger.error( "file_not_found", - extra={"component": "urls", "resource": filepath}, + extra={"component": "urls", "endpoint": "urls.load_urls_csv", "resource": filepath}, ) return jsonify(error=filename + " not found"), 404 diff --git a/app/routes/users.py b/app/routes/users.py index ba66a327..228c4724 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -37,6 +37,7 @@ def get_users_list(): "invalid_pagination_parameters", extra={ "component": "users", + "endpoint": "users.get_users_list", "param": "page_or_per_page", "value": request.args.get("page") or request.args.get("per_page"), }, @@ -68,7 +69,7 @@ def load_users_csv(): except FileNotFoundError: current_app.logger.error( "file_not_found", - extra={"component": "users", "resource": filepath}, + extra={"component": "users", "endpoint": "users.load_users_csv", "resource": filepath}, ) return jsonify(error=filename + " not found"), 404 From ce0c6c8f75fb9db8ea369a56c9a52fa1da5c5c2d Mon Sep 17 00:00:00 2001 From: Victor Date: Sat, 4 Apr 2026 18:50:58 -0400 Subject: [PATCH 13/14] log file permissions --- app/__init__.py | 40 +++++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index cf3e30f0..62761323 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -57,7 +57,7 @@ def format(self, record): def _configure_logging(app: Flask) -> None: level_name = os.environ.get("LOG_LEVEL", "INFO").upper() level = getattr(logging, level_name, logging.INFO) - log_file_path = os.environ.get("LOG_FILE_PATH", "/app/logs/app.log") + log_file_path = os.environ.get("LOG_FILE_PATH", "logs/app.log") log_max_bytes = int(os.environ.get("LOG_FILE_MAX_BYTES", str(10 * 1024 * 1024))) log_backup_count = int(os.environ.get("LOG_FILE_BACKUP_COUNT", "5")) @@ -65,18 +65,22 @@ def _configure_logging(app: Flask) -> None: stream_handler = logging.StreamHandler() stream_handler.setFormatter(formatter) - log_dir = os.path.dirname(log_file_path) - if log_dir: - os.makedirs(log_dir, exist_ok=True) - file_handler = RotatingFileHandler( - log_file_path, - maxBytes=log_max_bytes, - backupCount=log_backup_count, - encoding="utf-8", - ) - file_handler.setFormatter(formatter) - - handlers = [stream_handler, file_handler] + handlers = [stream_handler] + file_logging_error = None + try: + log_dir = os.path.dirname(log_file_path) + if log_dir: + os.makedirs(log_dir, exist_ok=True) + file_handler = RotatingFileHandler( + log_file_path, + maxBytes=log_max_bytes, + backupCount=log_backup_count, + encoding="utf-8", + ) + file_handler.setFormatter(formatter) + handlers.append(file_handler) + except OSError as exc: + file_logging_error = str(exc) app.logger.handlers.clear() for handler in handlers: @@ -96,6 +100,16 @@ def _configure_logging(app: Flask) -> None: extra={"endpoint": "app._configure_logging", "log_level": level_name, "resource": log_file_path}, ) + if file_logging_error: + app.logger.warning( + "file_logging_disabled", + extra={ + "endpoint": "app._configure_logging", + "resource": log_file_path, + "error": file_logging_error, + }, + ) + def create_app(): load_dotenv() From ac45352cb1657dccfe1990a84945f400d424381c Mon Sep 17 00:00:00 2001 From: Victor Date: Sat, 4 Apr 2026 18:59:00 -0400 Subject: [PATCH 14/14] missing import --- app/routes/urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/routes/urls.py b/app/routes/urls.py index db8b44f2..49781716 100644 --- a/app/routes/urls.py +++ b/app/routes/urls.py @@ -5,7 +5,7 @@ from datetime import datetime import base62 -from flask import Blueprint, current_app, jsonify, request +from flask import Blueprint, redirect, current_app, jsonify, request from app.cache import cache_get, cache_set, cache_delete, cache_delete_pattern from app.database import db