Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [5.17.2] - 2026-09-25

### Fixed

- **Idle dashboard 429 Too Many Requests (#767)** — Global `RATELIMIT_DEFAULT` was too strict (`50 per hour` per IP) for background timer polling. Default is now `5000 per day;1000 per hour`, keyed per logged-in user (IP for anonymous). Polling endpoints (`/timer/status`, `/api/timer/status`, `/api/timer/heartbeat`, `/api/notifications`, `/service-worker.js`, `/offline`) are exempt. JSON `Accept` / API requests get a JSON 429 body; floating timer bar and idle pollers back off on 429 and skip while the tab is hidden.
- **OpenTelemetry export respects telemetry opt-in** — OTLP export to the baked-in shared Grafana Cloud tenant now requires the telemetry opt-in (`ENABLE_TELEMETRY` / admin toggle). Operator-owned backends configured via explicit `OTEL_EXPORTER_OTLP_ENDPOINT` + `OTEL_EXPORTER_OTLP_TOKEN` still export without the opt-in. Metric attributes are normalized (status class, environment) and `http.server.duration` histogram buckets are tightened.

### Documentation

- **Version** — Bumped `setup.py` to **5.17.2**; `VERSION` and desktop/browser-extension/mobile client versions aligned.

## [5.17.1] - 2026-09-23

### Fixed
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ TimeTracker has been continuously enhanced with powerful new features! Here's wh
**Current version** is defined in `setup.py` (single source of truth). See [CHANGELOG.md](CHANGELOG.md) for versioned release history.

### ✨ Highlights of v5.17.2

**Patch (5.17.2):** **Idle dashboard no longer hits 429 (#767)** — higher per-user rate limit default, exempt timer/notification polling, and client back-off on 429. **OpenTelemetry opt-in** — export to the shared telemetry backend now requires the telemetry opt-in; operator-configured `OTEL_EXPORTER_OTLP_*` backends are unaffected. See [CHANGELOG.md](CHANGELOG.md#5172---2026-09-25).

### ✨ Highlights of v5.17.1

**Patch (5.17.1):** **Health probes exempt from rate limiting** — `/_health`, `/_ready`, and the API health endpoints no longer return 429 under frequent liveness checks, fixing Render restart loops and 502s. See [CHANGELOG.md](CHANGELOG.md#5171---2026-09-23).
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
5.17.1
5.17.2
21 changes: 20 additions & 1 deletion app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,33 @@ def protect(self):
return super().protect()


def _rate_limit_key():
"""Key rate limits per authenticated user; fall back to IP for anonymous.

Logged-in users behind a shared NAT/proxy get separate buckets so one
colleague's dashboard polling cannot exhaust another's limit (Issue #767).
Unauthenticated routes (login, etc.) still key by IP.
"""
try:
from flask_login import current_user

if current_user is not None and getattr(current_user, "is_authenticated", False):
user_id = getattr(current_user, "id", None)
if user_id is not None:
return f"user:{user_id}"
except Exception:
pass
return get_remote_address()


# Initialize extensions
db = SQLAlchemy()
migrate = Migrate()
login_manager = LoginManager()
socketio = SocketIO()
babel = Babel()
csrf = PathExemptCSRFProtect()
limiter = Limiter(key_func=get_remote_address, default_limits=[])
limiter = Limiter(key_func=_rate_limit_key, default_limits=[])
oauth = OAuth()

# Initialize Mail (will be configured in create_app)
Expand Down
5 changes: 3 additions & 2 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,9 @@ class Config:
PERF_QUERY_PROFILE = os.getenv("PERF_QUERY_PROFILE", "false").lower() == "true"

# Rate limiting
# Sensible default when unset; override via RATELIMIT_DEFAULT (semicolon/comma-separated).
RATELIMIT_DEFAULT = os.getenv("RATELIMIT_DEFAULT", "200 per day;50 per hour")
# Generous default for authenticated dashboards with background polling (Issue #767).
# Override via RATELIMIT_DEFAULT (semicolon/comma-separated). Keyed per user when logged in.
RATELIMIT_DEFAULT = os.getenv("RATELIMIT_DEFAULT", "5000 per day;1000 per hour")
RATELIMIT_STORAGE_URI = os.getenv("RATELIMIT_STORAGE_URI", "memory://")

# Redis configuration
Expand Down
3 changes: 3 additions & 0 deletions app/routes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ def api_version_dismiss():

@api_bp.route("/api/timer/status")
@login_required
@limiter.exempt
@deprecated_session_api("/api/v1/timer/status")
def timer_status():
"""Get current timer status"""
Expand Down Expand Up @@ -402,6 +403,7 @@ def timer_notes_suggestions():

@api_bp.route("/api/timer/heartbeat", methods=["POST"])
@login_required
@limiter.exempt
@deprecated_session_api("/api/v1/timer/heartbeat")
def api_timer_heartbeat():
"""Record activity for the active timer (idle timeout safety net)."""
Expand Down Expand Up @@ -2488,6 +2490,7 @@ def summary_today():

@api_bp.route("/api/notifications")
@login_required
@limiter.exempt
def api_smart_notifications():
"""Smart in-app notification candidates (respects preferences, dismissals, caps)."""
from app.services.notification_service import NotificationService
Expand Down
2 changes: 2 additions & 0 deletions app/routes/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -932,6 +932,7 @@ def manifest():


@main_bp.route("/offline")
@limiter.exempt
def offline_page():
"""Public offline fallback for PWA (no login required)."""
resp = make_response(render_template("offline.html"))
Expand All @@ -940,6 +941,7 @@ def offline_page():


@main_bp.route("/service-worker.js")
@limiter.exempt
def service_worker():
"""Site-scoped service worker; implementation lives in app/static/js/sw.js."""
return send_from_directory(current_app.static_folder, "js/sw.js", mimetype="application/javascript")
Expand Down
3 changes: 2 additions & 1 deletion app/routes/timer.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from sqlalchemy import inspect, text
from sqlalchemy.exc import ProgrammingError

from app import db, log_event, socketio, track_event
from app import db, limiter, log_event, socketio, track_event
from app.constants import TimeEntrySource
from app.models import Activity, Client, Project, Settings, Task, TimeEntry, User
from app.services.client_service import ClientService
Expand Down Expand Up @@ -890,6 +890,7 @@ def set_timer_start():

@timer_bp.route("/timer/status")
@login_required
@limiter.exempt
def timer_status():
"""Get current timer status as JSON"""
from app.models import Settings
Expand Down
31 changes: 29 additions & 2 deletions app/static/floating-timer-bar.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
'use strict';

const POLL_INTERVAL_MS = 30000;
const FOCUS_REFETCH_THROTTLE_MS = 10000;
const DEFAULT_429_BACKOFF_MS = 60000;

function syncFabDesktopHide(timerData) {
try {
Expand All @@ -29,6 +31,8 @@
this.switchLabel = 'Switch project';
this.projectsCache = null;
this.switchPopover = null;
this.backoffUntil = 0;
this.lastFocusFetch = 0;
this.init();
}

Expand All @@ -44,7 +48,12 @@
this.render();
this.fetchStatus();
this.pollTimer = setInterval(() => this.fetchStatus(), POLL_INTERVAL_MS);
window.addEventListener('focus', () => this.fetchStatus());
window.addEventListener('focus', () => {
const now = Date.now();
if (now - this.lastFocusFetch < FOCUS_REFETCH_THROTTLE_MS) return;
this.lastFocusFetch = now;
this.fetchStatus();
});
document.addEventListener('click', (evt) => {
if (!this.switchPopover || this.switchPopover.classList.contains('hidden')) return;
if (this.switchPopover.contains(evt.target)) return;
Expand Down Expand Up @@ -151,8 +160,26 @@
}

async fetchStatus() {
if (document.hidden) return;
if (Date.now() < this.backoffUntil) return;
try {
const res = await fetch('/timer/status', { credentials: 'same-origin' });
const res = await fetch('/timer/status', {
credentials: 'same-origin',
headers: { 'Accept': 'application/json' },
});
if (res.status === 429) {
const retryAfter = parseInt(res.headers.get('Retry-After'), 10);
const waitMs = (!isNaN(retryAfter) && retryAfter > 0)
? retryAfter * 1000
: DEFAULT_429_BACKOFF_MS;
this.backoffUntil = Date.now() + waitMs;
console.warn('FloatingTimerBar: rate limited, backing off', waitMs, 'ms');
return;
}
if (!res.ok) {
console.warn('FloatingTimerBar: fetch status failed', res.status);
return;
}
const data = await res.json();
if (data.active && data.timer) {
this.timerData = data.timer;
Expand Down
4 changes: 4 additions & 0 deletions app/static/idle.js
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,10 @@
}

async function tick(){
// Skip status polling while the tab is hidden to avoid burning rate-limit
// budget when the user is idle on another tab (Issue #767). Heartbeats still
// fire from markActive() / sendHeartbeat() when the user returns.
if (document.hidden) return;
const active = await getTimer();
hasActiveTimer = !!active;
if (!active) return;
Expand Down
Loading
Loading