feat: implement docs-site, legal docs, metrics standard, flux manifests
Closes leeworks-agents/api-company#5 (docs-site Astro scaffold) Closes leeworks-agents/api-company#9 (metrics instrumentation standard) Closes leeworks-agents/api-company#10 (Gitea Actions openapi aggregation pipeline) Closes leeworks-agents/api-company#11 (docs-site Flux HelmRelease) Closes leeworks-agents/api-company#12 (SEO blog posts x3) Closes leeworks-agents/api-company#13 (legal docs ToS/Privacy/AUP) Closes leeworks-agents/api-company#14 (DNS documentation) ## Changes ### docs/legal/ - terms-of-service.md — API usage, liability, account termination, governing law - privacy-policy.md — request log retention (90d), no PII sold, data sharing - acceptable-use-policy.md — rate limit abuse, scraping prohibition, resale ban ### docs/metrics-standard.md - Defines api_requests_total, api_response_duration_seconds, api_data_freshness_seconds - Fastify (TypeScript) and FastAPI (Python) reference middleware implementations - Prometheus scrape config and Grafana dashboard guidance ### docs/registry.md - Decision: use Gitea built-in container registry (no new infra) - Image naming convention, auth, Kubernetes imagePullSecrets, ingress config ### docs/dns.md - Required A records for all 6 subdomains - cert-manager ClusterIssuer and Ingress TLS examples - Verification commands and human-operator action items ### docs-site/ - Astro 4 + MDX + sitemap scaffold - Base layout with nav linking all APIs, blog, RapidAPI, status - Landing page with API cards - Per-API Redoc viewer pages (zip-enrichment, holidays, air-quality) - Blog index + 3 SEO blog posts (~1000 words each with JSON-LD) - Dockerfile (multi-stage: node build + nginx serve) - nginx.conf with gzip, caching, health endpoint ### flux/ - gitea-runner/: gitea-act-runner HelmRelease (org-scope, dind) - monitoring/: kube-prometheus-stack + Gatus HelmReleases - Prometheus with pod annotation scraping - Grafana at grafana.leeworks.dev with persistence - Gatus status page at status.leeworks.dev, 90-day retention - docs-site/: Deployment + Service + Ingress via raw chart - api-company-source/: GitRepository + Kustomization reference manifests - kustomization.yaml: root kustomize entry point (build validated) ### .gitea/workflows/build-docs.yaml - Aggregates openapi.yaml from zip-enrichment, holidays, air-quality repos - Builds Astro docs-site - Pushes image to registry.leeworks.dev/leeworks-agents/docs-site - Triggered on push to main, schedule daily 02:00 UTC, workflow_dispatch
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
# API Metrics Instrumentation Standard
|
||||
|
||||
**Version:** 1.0
|
||||
**Date:** 2026-05-24
|
||||
**Applies to:** All leeworks.dev API services (zip-enrichment, holidays, air-quality)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Every API service MUST expose Prometheus-compatible metrics at `GET /metrics`. This document defines the required metrics, label conventions, and provides reference middleware implementations for both Fastify (Node.js) and FastAPI (Python).
|
||||
|
||||
---
|
||||
|
||||
## Required Metrics
|
||||
|
||||
### 1. `api_requests_total`
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Type** | Counter |
|
||||
| **Description** | Total number of HTTP requests received |
|
||||
| **Labels** | `api`, `route`, `method`, `status` |
|
||||
|
||||
**Label values:**
|
||||
- `api`: one of `zip-enrichment`, `holidays`, `air-quality`
|
||||
- `route`: the matched route pattern, e.g. `/v1/lookup`, `/v1/holidays`
|
||||
- `method`: HTTP method, e.g. `GET`, `POST`
|
||||
- `status`: HTTP status code as string, e.g. `200`, `404`, `429`, `403`
|
||||
|
||||
**Example:**
|
||||
```
|
||||
api_requests_total{api="zip-enrichment",route="/v1/lookup",method="GET",status="200"} 1234
|
||||
api_requests_total{api="zip-enrichment",route="/v1/lookup",method="GET",status="429"} 12
|
||||
api_requests_total{api="zip-enrichment",route="/v1/lookup",method="GET",status="403"} 3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. `api_response_duration_seconds`
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Type** | Histogram |
|
||||
| **Description** | HTTP response latency in seconds |
|
||||
| **Labels** | `api`, `route` |
|
||||
| **Buckets** | `0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5` |
|
||||
|
||||
**Example:**
|
||||
```
|
||||
api_response_duration_seconds_bucket{api="holidays",route="/v1/holidays",le="0.05"} 800
|
||||
api_response_duration_seconds_bucket{api="holidays",route="/v1/holidays",le="0.1"} 990
|
||||
api_response_duration_seconds_sum{api="holidays",route="/v1/holidays"} 45.2
|
||||
api_response_duration_seconds_count{api="holidays",route="/v1/holidays"} 1000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. `api_data_freshness_seconds`
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Type** | Gauge |
|
||||
| **Description** | Seconds since the local dataset was last seeded/refreshed |
|
||||
| **Labels** | `api`, `dataset` |
|
||||
| **Unit** | Seconds (Unix timestamp diff: `now - last_seed_time`) |
|
||||
|
||||
**Label values:**
|
||||
- `dataset`: a descriptive name for the dataset, e.g. `zip_codes`, `us_holidays`, `aqi_readings`
|
||||
|
||||
**Example:**
|
||||
```
|
||||
api_data_freshness_seconds{api="air-quality",dataset="aqi_readings"} 86400
|
||||
api_data_freshness_seconds{api="zip-enrichment",dataset="zip_codes"} 2592000
|
||||
```
|
||||
|
||||
A value of `0` means freshly seeded; values growing toward `2592000` (30 days) are expected for monthly re-seed schedules.
|
||||
|
||||
---
|
||||
|
||||
## Reference Implementations
|
||||
|
||||
### Fastify (Node.js/TypeScript)
|
||||
|
||||
Install dependencies:
|
||||
```bash
|
||||
npm install prom-client
|
||||
```
|
||||
|
||||
**`src/metrics.ts`:**
|
||||
```typescript
|
||||
import { Registry, Counter, Histogram, Gauge } from 'prom-client';
|
||||
|
||||
export const register = new Registry();
|
||||
|
||||
export const requestsTotal = new Counter({
|
||||
name: 'api_requests_total',
|
||||
help: 'Total number of HTTP requests received',
|
||||
labelNames: ['api', 'route', 'method', 'status'],
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
export const responseDuration = new Histogram({
|
||||
name: 'api_response_duration_seconds',
|
||||
help: 'HTTP response latency in seconds',
|
||||
labelNames: ['api', 'route'],
|
||||
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5],
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
export const dataFreshness = new Gauge({
|
||||
name: 'api_data_freshness_seconds',
|
||||
help: 'Seconds since the local dataset was last seeded',
|
||||
labelNames: ['api', 'dataset'],
|
||||
registers: [register],
|
||||
});
|
||||
```
|
||||
|
||||
**`src/metricsMiddleware.ts`:**
|
||||
```typescript
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { register, requestsTotal, responseDuration } from './metrics';
|
||||
|
||||
const API_NAME = process.env.API_NAME ?? 'unknown'; // set per service
|
||||
|
||||
export const metricsPlugin: FastifyPluginAsync = async (fastify) => {
|
||||
// Expose /metrics endpoint
|
||||
fastify.get('/metrics', async (_req, reply) => {
|
||||
reply.header('Content-Type', register.contentType);
|
||||
return register.metrics();
|
||||
});
|
||||
|
||||
// Instrument all routes
|
||||
fastify.addHook('onRequest', async (request, _reply) => {
|
||||
(request as any)._startTime = process.hrtime.bigint();
|
||||
});
|
||||
|
||||
fastify.addHook('onResponse', async (request, reply) => {
|
||||
const startTime = (request as any)._startTime as bigint;
|
||||
const durationMs = Number(process.hrtime.bigint() - startTime) / 1e6;
|
||||
const route = request.routerPath ?? request.url;
|
||||
|
||||
requestsTotal.labels(API_NAME, route, request.method, String(reply.statusCode)).inc();
|
||||
responseDuration.labels(API_NAME, route).observe(durationMs / 1000);
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
**Register in main:**
|
||||
```typescript
|
||||
import { metricsPlugin } from './metricsMiddleware';
|
||||
await fastify.register(metricsPlugin);
|
||||
```
|
||||
|
||||
**Update data freshness gauge (call after each seed):**
|
||||
```typescript
|
||||
import { dataFreshness } from './metrics';
|
||||
// Call this after each DB seed completes:
|
||||
dataFreshness.labels('zip-enrichment', 'zip_codes').set(0);
|
||||
// Or set it to seconds since last seed on startup:
|
||||
dataFreshness.labels('zip-enrichment', 'zip_codes').set(secondsSinceLastSeed);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### FastAPI (Python)
|
||||
|
||||
Install dependencies:
|
||||
```bash
|
||||
pip install prometheus-client starlette
|
||||
```
|
||||
|
||||
**`metrics.py`:**
|
||||
```python
|
||||
from prometheus_client import Counter, Histogram, Gauge, REGISTRY, CollectorRegistry
|
||||
|
||||
registry = CollectorRegistry()
|
||||
|
||||
requests_total = Counter(
|
||||
'api_requests_total',
|
||||
'Total number of HTTP requests received',
|
||||
['api', 'route', 'method', 'status'],
|
||||
registry=registry,
|
||||
)
|
||||
|
||||
response_duration = Histogram(
|
||||
'api_response_duration_seconds',
|
||||
'HTTP response latency in seconds',
|
||||
['api', 'route'],
|
||||
buckets=[0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5],
|
||||
registry=registry,
|
||||
)
|
||||
|
||||
data_freshness = Gauge(
|
||||
'api_data_freshness_seconds',
|
||||
'Seconds since the local dataset was last seeded',
|
||||
['api', 'dataset'],
|
||||
registry=registry,
|
||||
)
|
||||
```
|
||||
|
||||
**`metrics_middleware.py`:**
|
||||
```python
|
||||
import time
|
||||
import os
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from prometheus_client import generate_latest, CONTENT_TYPE_LATEST
|
||||
from metrics import requests_total, response_duration, registry
|
||||
|
||||
API_NAME = os.getenv("API_NAME", "unknown")
|
||||
|
||||
|
||||
class MetricsMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
start = time.time()
|
||||
response = await call_next(request)
|
||||
duration = time.time() - start
|
||||
|
||||
route = request.url.path
|
||||
requests_total.labels(
|
||||
api=API_NAME,
|
||||
route=route,
|
||||
method=request.method,
|
||||
status=str(response.status_code),
|
||||
).inc()
|
||||
response_duration.labels(api=API_NAME, route=route).observe(duration)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
async def metrics_endpoint(request: Request):
|
||||
return Response(
|
||||
generate_latest(registry),
|
||||
media_type=CONTENT_TYPE_LATEST,
|
||||
)
|
||||
```
|
||||
|
||||
**Register in FastAPI app:**
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
from starlette.routing import Route
|
||||
from metrics_middleware import MetricsMiddleware, metrics_endpoint
|
||||
|
||||
app = FastAPI()
|
||||
app.add_middleware(MetricsMiddleware)
|
||||
app.add_route("/metrics", metrics_endpoint)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Prometheus Scrape Configuration
|
||||
|
||||
Add to your Prometheus `scrape_configs` (or ServiceMonitor for kube-prometheus-stack):
|
||||
|
||||
```yaml
|
||||
# prometheus-additional-scrapes.yaml
|
||||
- job_name: 'leeworks-apis'
|
||||
kubernetes_sd_configs:
|
||||
- role: pod
|
||||
relabel_configs:
|
||||
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
|
||||
action: keep
|
||||
regex: "true"
|
||||
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]
|
||||
action: replace
|
||||
target_label: __metrics_path__
|
||||
regex: (.+)
|
||||
- source_labels: [__address__, __meta_kubernetes_pod_annotation_prometheus_io_port]
|
||||
action: replace
|
||||
regex: ([^:]+)(?::\d+)?;(\d+)
|
||||
replacement: $1:$2
|
||||
target_label: __address__
|
||||
```
|
||||
|
||||
Add annotations to each API pod:
|
||||
```yaml
|
||||
annotations:
|
||||
prometheus.io/scrape: "true"
|
||||
prometheus.io/port: "3000" # or 8000 for FastAPI
|
||||
prometheus.io/path: "/metrics"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Grafana Dashboard
|
||||
|
||||
A reference dashboard JSON is available at `docs/grafana-api-dashboard.json` (TBD — will be committed once Grafana is deployed per issue #7).
|
||||
|
||||
Key panels to include:
|
||||
1. Request rate by API and status (`rate(api_requests_total[5m])`)
|
||||
2. P50/P95/P99 latency (`histogram_quantile(0.99, rate(api_response_duration_seconds_bucket[5m]))`)
|
||||
3. Error rate = non-2xx / total requests
|
||||
4. Data freshness gauge per API
|
||||
5. Request volume heatmap
|
||||
|
||||
---
|
||||
|
||||
## Compliance Checklist
|
||||
|
||||
Before marking an API server PR as ready:
|
||||
|
||||
- [ ] `GET /metrics` returns `text/plain; version=0.0.4; charset=utf-8`
|
||||
- [ ] `api_requests_total` increments on every request with correct labels
|
||||
- [ ] `api_response_duration_seconds` has observations on every request
|
||||
- [ ] `api_data_freshness_seconds` is set on startup and after each seed
|
||||
- [ ] Pod annotations for Prometheus scraping are present in the Helm chart values
|
||||
- [ ] `API_NAME` env var is set correctly per deployment
|
||||
Reference in New Issue
Block a user