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
3 changes: 3 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ Note: Minor version `0.X.0` update might break the API, It's recommended to pin

## [unreleased]

* add: optional Prometheus metrics endpoint at `/metrics` with low-cardinality `operation` labels
* fix: exclude `/healthz` and `/metrics` from Cache-Control headers

## [1.4.0] - 2026-06-11

* change: switch from `pygeofilter` to `cql2` for CQL filter parsing and evaluation
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ services:
- POSTGRES_HOST=database
- POSTGRES_PORT=5432
- DEBUG=TRUE
- TIPG_ENABLE_METRICS=TRUE
ports:
- "${MY_DOCKER_IP:-127.0.0.1}:8081:8081"
depends_on:
Expand Down
2 changes: 1 addition & 1 deletion dockerfiles/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ COPY LICENSE LICENSE
COPY tipg/ tipg/
COPY pyproject.toml pyproject.toml

RUN python -m pip install . --no-cache-dir
RUN python -m pip install ".[metrics]" --no-cache-dir
Comment thread
vincentsarago marked this conversation as resolved.
RUN rm -rf tipg/ README.md pyproject.toml LICENSE

RUN groupadd -g 1000 user && \
Expand Down
51 changes: 50 additions & 1 deletion docs/src/user_guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,57 @@ prefix: **`TIPG_`**
- **NAME** (str): Set custom name for `TiPG` app. Default is `TiPg: OGC Features and Tiles API`
- **DEBUG** (bool): Default is `False`
- **CORS_ORIGIN** (str): Default is `*`
- **CACHECONTROL** (str): Default is `public, max-age=3600`
- **CACHECONTROL** (str): Default is `public, max-age=3600`. `/healthz` and `/metrics` are excluded.
- **TEMPLATE_DIRECTORY** (str): Path to custom template directory to overwrite the HTML files.
- **ROOT_PATH** (str): A path prefix handled by a proxy that is not seen by the application but is seen by external clients.
- **ADD_TILES_VIEWER** (bool): Defaults is `True`
- **ENABLE_METRICS** (bool): Enable the Prometheus `/metrics` endpoint. Default is `False`. Requires the optional `metrics` extra.
- **CATALOG_TTL** (int, in seconds): Tables/Functions catalog **Time To Live** cache (default to 300 seconds).

## Prometheus metrics

Install the optional `metrics` extra and set `TIPG_ENABLE_METRICS=TRUE` to expose HTTP request metrics at `/metrics`:

```bash
pip install 'tipg[metrics]'
export TIPG_ENABLE_METRICS=TRUE
```

The project Docker image installs the `metrics` extra. Enable the endpoint with `TIPG_ENABLE_METRICS=TRUE`.

Once enabled, `/metrics` records:

- `tipg_http_requests_total{operation,method,status}`
- `tipg_http_request_duration_seconds{operation,method}`

`operation` values are low-cardinality labels: `landing`, `conformance`, `list_collections`, `get_collection`, `queryables`, `list_items`, `get_item`, `tiles`, `tile_matrix_sets`, `other`, and `unknown`. `status` is grouped (`2xx`, `3xx`, `4xx`, `5xx`). `/healthz` and the scrape endpoint are excluded from request counters. Untemplated paths (for example bare 404s with no matching route) are also ignored. `/healthz` and `/metrics` are also excluded from Cache-Control so probes and scrapes are not cached by CDNs or proxies.

### Multi-worker deployments

For multi-worker deployments (for example `uvicorn --workers N` or Gunicorn), set `PROMETHEUS_MULTIPROC_DIR` to an existing writable directory **before** the application is imported, and clear that directory before each server start. An invalid path fails startup with `ValueError`.

```bash
export PROMETHEUS_MULTIPROC_DIR=/tmp/tipg-prometheus
mkdir -p "$PROMETHEUS_MULTIPROC_DIR"
rm -rf "$PROMETHEUS_MULTIPROC_DIR"/*
```

With Gunicorn, also mark workers dead on exit so stale metric files are cleaned up:

```python
from prometheus_client import multiprocess


def child_exit(server, worker):
multiprocess.mark_process_dead(worker.pid)
```

### Custom apps

Custom apps can enable the same instrumentation:

```python
from tipg.metrics import instrument_app

instrument_app(app)
```
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ dev = [
"numpy",
"pre-commit",
"bump-my-version",
"tipg[metrics]",
]
server = [
"uvicorn[standard]>=0.12.0,<0.19.0",
Expand All @@ -59,6 +60,11 @@ docs = [
"mkdocstrings[python]>=0.25.1",
]

[project.optional-dependencies]
metrics = [
"prometheus-fastapi-instrumentator>=8.0.2",
]

[project.urls]
Homepage = "https://developmentseed.org/tipg"
Source = "https://github.com/developmentseed/tipg"
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ def app(database_url, monkeypatch):

# API config
monkeypatch.setenv("TIPG_TEMPLATE_DIRECTORY", TEMPLATE_DIRECTORY)
monkeypatch.setenv("TIPG_ENABLE_METRICS", "TRUE")

# Custom Functions
monkeypatch.setenv("TIPG_CUSTOM_SQL_DIRECTORY", SQL_FUNCTIONS_DIRECTORY)
Expand Down
1 change: 1 addition & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ def test_health(app):
response = app.get("/healthz")
assert response.status_code == 200
assert response.json() == {"ping": "pong!"}
assert "Cache-Control" not in response.headers

response = app.get("/rawcatalog")
assert response.status_code == 200
Expand Down
262 changes: 262 additions & 0 deletions tests/test_metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
"""Test tipg Prometheus metrics."""

import os
import subprocess
import sys
import textwrap
from pathlib import Path

import pytest

from tipg.metrics import REQUESTS, instrument_app, resolve_operation

from fastapi import FastAPI

from starlette.testclient import TestClient


def _counter_total() -> float:
"""Sum tipg request counter samples across all label sets."""
total = 0.0
for metric in REQUESTS.collect():
for sample in metric.samples:
if sample.name == "tipg_http_requests_total":
total += sample.value
return total


@pytest.mark.parametrize(
"route,expected",
[
("/", "landing"),
("/conformance", "conformance"),
("/collections", "list_collections"),
("/collections/{collectionId}", "get_collection"),
("/collections/{collectionId}/queryables", "queryables"),
("/collections/{collectionId}/items", "list_items"),
("/collections/{collectionId}/items/{itemId}", "get_item"),
("/collections/{collectionId}/tiles", "tiles"),
(
"/collections/{collectionId}/tiles/{tileMatrixSetId}/{z}/{x}/{y}",
"tiles",
),
("/tileMatrixSets", "tile_matrix_sets"),
("/tileMatrixSets/{tileMatrixSetId}", "tile_matrix_sets"),
(None, "unknown"),
("/api", "other"),
],
)
def test_resolve_operation(route, expected):
"""Map tipg route templates to low-cardinality operation labels."""
assert resolve_operation(route) == expected


def test_metrics_endpoint(app):
"""Metrics returns Prometheus exposition with tipg operation labels."""
assert app.get("/collections").status_code == 200

response = app.get("/metrics")
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/plain")
assert "Cache-Control" not in response.headers
assert "tipg_http_requests_total{" in response.text
assert 'operation="list_collections"' in response.text
assert "tipg_http_request_duration_seconds_bucket{" in response.text
assert 'status="2xx"' in response.text


def test_metrics_exclude_healthz_and_metrics(app):
"""Health and scrape endpoints are not counted under any operation label."""
before = _counter_total()
assert app.get("/healthz").status_code == 200
assert app.get("/metrics").status_code == 200
assert _counter_total() == before


def test_healthz_exclusion_is_exact():
"""Only the exact /healthz path is excluded from request counters."""
app = FastAPI()

@app.get("/foo/healthz-report")
def healthz_report():
return {"ok": True}

instrument_app(app)

with TestClient(app) as client:
before = _counter_total()
assert client.get("/foo/healthz-report").status_code == 200
assert _counter_total() == before + 1


def test_custom_metrics_endpoint_excluded():
"""A custom scrape endpoint is excluded from request counters."""
app = FastAPI()

@app.get("/hello")
def hello():
return {"ok": True}

instrument_app(app, endpoint="/prom")

with TestClient(app) as client:
before = _counter_total()
assert client.get("/prom").status_code == 200
assert _counter_total() == before

assert client.get("/hello").status_code == 200
assert _counter_total() == before + 1
body = client.get("/prom").text
assert 'operation="other"' in body


def test_instrument_app_is_idempotent():
"""Calling instrument_app twice on the same app is a no-op."""
app = FastAPI()
instrument_app(app)
instrument_app(app)

metric_routes = [
getattr(route, "path", None)
for route in app.routes
if getattr(route, "path", None) == "/metrics"
]
assert len(metric_routes) == 1


def test_optional_metrics_import_error(tmp_path: Path):
"""Missing prometheus packages raise ImportError from tipg.metrics."""
script = textwrap.dedent(
"""
import builtins
import sys

real_import = builtins.__import__

def fake_import(name, globals=None, locals=None, fromlist=(), level=0):
if name.startswith("prometheus_"):
raise ImportError(name)
return real_import(name, globals, locals, fromlist, level)

builtins.__import__ = fake_import
sys.modules.pop("tipg.metrics", None)

try:
import tipg.metrics # noqa: F401
except ImportError:
raise SystemExit(0)
raise SystemExit(1)
"""
)
script_path = tmp_path / "check_import_error.py"
script_path.write_text(script)
result = subprocess.run(
[sys.executable, str(script_path)],
check=False,
capture_output=True,
text=True,
cwd=str(Path(__file__).resolve().parents[1]),
env={**os.environ, "PYTHONPATH": str(Path(__file__).resolve().parents[1])},
)
assert result.returncode == 0, result.stderr


def test_instrument_app_errors_are_not_swallowed(monkeypatch):
"""Non-ImportError failures from instrumentation propagate to the caller."""
import tipg.metrics as metrics

app = FastAPI()
metrics._INSTRUMENTED_APPS.discard(app)

def boom(*args, **kwargs):
raise ValueError("PROMETHEUS_MULTIPROC_DIR is invalid")

monkeypatch.setattr(metrics, "Instrumentator", boom)
with pytest.raises(ValueError, match="PROMETHEUS_MULTIPROC_DIR"):
instrument_app(app)


def test_main_propagates_instrumentation_errors(tmp_path: Path):
"""tipg.main only swallows ImportError from importing tipg.metrics."""
script = textwrap.dedent(
"""
import sys
from types import ModuleType

fake = ModuleType("tipg.metrics")

def instrument_app(app):
raise ImportError("broken installed metrics dependency")

fake.instrument_app = instrument_app
sys.modules["tipg.metrics"] = fake
sys.modules.pop("tipg.main", None)

try:
import tipg.main # noqa: F401
except ImportError as exc:
if "broken installed metrics dependency" in str(exc):
raise SystemExit(0)
raise
raise SystemExit(1)
"""
)
script_path = tmp_path / "check_main_import.py"
script_path.write_text(script)
result = subprocess.run(
[sys.executable, str(script_path)],
check=False,
capture_output=True,
text=True,
cwd=str(Path(__file__).resolve().parents[1]),
env={
**os.environ,
"PYTHONPATH": str(Path(__file__).resolve().parents[1]),
"DATABASE_URL": "postgresql://user:pass@localhost:5432/db",
},
)
assert result.returncode == 0, result.stderr


def test_multiprocess_metrics_exposition(tmp_path: Path):
"""Multiprocess scrape works when PROMETHEUS_MULTIPROC_DIR is set before import."""
multiproc_dir = tmp_path / "multiproc"
multiproc_dir.mkdir()
script = textwrap.dedent(
f"""
import os

os.environ["PROMETHEUS_MULTIPROC_DIR"] = {str(multiproc_dir)!r}

from fastapi import FastAPI
from starlette.testclient import TestClient

from tipg.metrics import instrument_app

app = FastAPI()

@app.get("/collections")
def collections():
return []

instrument_app(app)

with TestClient(app) as client:
assert client.get("/collections").status_code == 200
response = client.get("/metrics")
assert response.status_code == 200
assert "tipg_http_requests_total" in response.text
assert 'operation="list_collections"' in response.text
"""
)
script_path = tmp_path / "check_multiproc.py"
script_path.write_text(script)
result = subprocess.run(
[sys.executable, str(script_path)],
check=False,
capture_output=True,
text=True,
cwd=str(Path(__file__).resolve().parents[1]),
env={**os.environ, "PYTHONPATH": str(Path(__file__).resolve().parents[1])},
)
assert result.returncode == 0, result.stderr
Loading