Skip to content

Commit a9d75d0

Browse files
committed
feat(api): add benchmark write endpoint and archive specs
1 parent ccaa191 commit a9d75d0

23 files changed

Lines changed: 488 additions & 8 deletions

File tree

apps/api/README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,21 @@ Minimal Go service for the public `events.vercount.one` surface.
77
- `/`
88
- `/healthz`
99
- `/js`
10+
- `/bench/write`
1011
- `/log`
1112
- `/api/v1/log`
1213
- `/api/v2/log`
1314

15+
`GET /bench/write` is a public benchmark helper for external probe tools. It always writes to the fixed synthetic target `https://bench.vercount.one/gurt`, returns the same v2-style JSON envelope used by the modern public API, and sends no-store cache headers so latency checks hit the Go service instead of cached content.
16+
1417
## Internal structure
1518

1619
The Go API stays intentionally small and is organized by runtime surface instead of generic web layers:
1720

1821
- `internal/app/runtime.go` - env/config loading and logger setup
1922
- `internal/app/server.go` - dependency wiring and chi route registration
2023
- `internal/api/public.go` - `/`, `/healthz`, and `/js`
21-
- `internal/api/log.go` - `/log`, `/api/v1/log`, and `/api/v2/log`
24+
- `internal/api/log.go` - `/bench/write`, `/log`, `/api/v1/log`, and `/api/v2/log`
2225
- `internal/counter/*` - counter reads/writes and Redis-backed counting behavior
2326

2427
## Environment
@@ -123,8 +126,9 @@ If GHCR creates the package with private visibility on first publish, update the
123126

124127
1. Build the web app script so `apps/web/public/js/client.min.js` is up to date.
125128
2. Deploy this service behind `events.vercount.one`.
126-
3. Verify `/js`, `/log`, `/api/v1/log`, and `/api/v2/log` on the new host.
127-
4. Confirm the dashboard in `apps/web` still reads the shared Redis data correctly.
129+
3. Verify `/js`, `/bench/write`, `/log`, `/api/v1/log`, and `/api/v2/log` on the new host.
130+
4. Confirm `/bench/write` writes only to the fixed synthetic benchmark target `https://bench.vercount.one/gurt` and returns an uncached v2-style success envelope.
131+
5. Confirm the dashboard in `apps/web` still reads the shared Redis data correctly.
128132

129133
## Rollback
130134

apps/api/internal/api/log.go

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@ import (
1919
)
2020

2121
const (
22-
rateLimitWindow = 60 * time.Second
23-
rateLimitCount = int64(80)
22+
rateLimitWindow = 60 * time.Second
23+
rateLimitCount = int64(80)
24+
benchmarkWriteTargetURL = "https://bench.vercount.one/gurt"
2425
)
2526

2627
var suspiciousUA = regexp.MustCompile(`python-requests|python/|requests/|curl/|wget/|go-http-client/|httpie/|postman/|axios/|node-fetch/|empty|unknown|bot|crawl|spider`)
@@ -226,6 +227,31 @@ func (h *LogHandler) V2Post(w http.ResponseWriter, r *http.Request) {
226227
h.writeV2Success(w, http.StatusOK, "Data updated successfully", counts)
227228
}
228229

230+
func (h *LogHandler) BenchmarkWrite(w http.ResponseWriter, r *http.Request) {
231+
applyCORSHeaders(w)
232+
applyNoStoreHeaders(w)
233+
if !h.allowV2Request(w, r) {
234+
return
235+
}
236+
237+
target, message := validateTargetURL(benchmarkWriteTargetURL)
238+
if message != "" {
239+
h.log.Error("benchmark target configuration invalid", requestLogFields(r, "benchmark.target.invalid", map[string]any{"status": http.StatusInternalServerError, "target_url": benchmarkWriteTargetURL, "reason": message}))
240+
h.writeV2Error(w, http.StatusInternalServerError, "Benchmark target configuration invalid", nil)
241+
return
242+
}
243+
244+
counts, err := h.writeCounts(r.Context(), target.Host, target.Path, true)
245+
if err != nil {
246+
h.log.Error("benchmark counter update failed", requestLogFields(r, "counter.benchmark_write_failed", map[string]any{"status": http.StatusInternalServerError, "error": err.Error(), "target_url": benchmarkWriteTargetURL}))
247+
h.writeV2Error(w, http.StatusInternalServerError, "Internal server error", nil)
248+
return
249+
}
250+
251+
h.log.Debug("benchmark counter update completed", requestLogFields(r, "counter.benchmark_write_completed", map[string]any{"host": target.Host, "target_path": target.Path, "target_url": benchmarkWriteTargetURL, "is_new_uv": true, "site_uv": counts.SiteUV, "site_pv": counts.SitePV, "page_pv": counts.PagePV}))
252+
h.writeV2Success(w, http.StatusOK, "Benchmark data updated successfully", counts)
253+
}
254+
229255
func (h *LogHandler) allowV1Request(w http.ResponseWriter, r *http.Request) bool {
230256
result := h.limit.Check(r.Context(), r)
231257
if !result.Success {

apps/api/internal/api/public.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,18 +32,19 @@ func NewPublicHandler(scriptPath string, log Logger, redisClient *redis.Client)
3232

3333
func (h *PublicHandler) Root(w http.ResponseWriter, _ *http.Request) {
3434
writeJSON(w, http.StatusOK, map[string]any{
35-
"service": publicServiceName,
35+
"service": publicServiceName,
3636
"description": "Straightforward, Fast, and Reliable Website Visitor Counter.",
37-
"status": "ok",
37+
"status": "ok",
3838
"routes": []string{
3939
"/",
4040
"/healthz",
4141
"/js",
42+
"/bench/write",
4243
// "/log", deprecated
4344
"/api/v1/log",
4445
"/api/v2/log",
4546
},
46-
"github": "https://github.com/EvanNotFound/vercount",
47+
"github": "https://github.com/EvanNotFound/vercount",
4748
"homepage": "https://www.vercount.one",
4849
})
4950
}
@@ -103,3 +104,9 @@ func applyCORSHeaders(w http.ResponseWriter) {
103104
w.Header().Set("Access-Control-Allow-Headers", "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-Browser-Token")
104105
w.Header().Set("Access-Control-Max-Age", "86400")
105106
}
107+
108+
func applyNoStoreHeaders(w http.ResponseWriter) {
109+
w.Header().Set("Cache-Control", "no-store, max-age=0")
110+
w.Header().Set("Pragma", "no-cache")
111+
w.Header().Set("Expires", "0")
112+
}

apps/api/internal/app/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ func (s *Server) Routes() http.Handler {
3434
r.Get("/healthz", s.public.Healthz)
3535
r.Get("/js", s.public.Script)
3636
r.Head("/js", s.public.Script)
37+
r.Get("/bench/write", s.logAPI.BenchmarkWrite)
3738

3839
r.Options("/log", s.logAPI.V1Options)
3940
r.Get("/log", s.logAPI.V1Get)

apps/api/internal/app/server_test.go

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package app
22

33
import (
44
"bytes"
5+
"context"
56
"encoding/json"
67
"net/http"
78
"net/http/httptest"
@@ -38,6 +39,10 @@ func TestRootEndpointReturnsServiceMetadata(t *testing.T) {
3839
if !ok || len(routes) == 0 {
3940
t.Fatalf("expected routes array, got %#v", payload["routes"])
4041
}
42+
43+
if !containsRoute(routes, "/bench/write") {
44+
t.Fatalf("expected benchmark route in metadata, got %#v", routes)
45+
}
4146
}
4247

4348
func TestHealthzReportsReadyWhenRedisReachable(t *testing.T) {
@@ -196,6 +201,146 @@ func TestOptionsRoutesStayAvailable(t *testing.T) {
196201
}
197202
}
198203

204+
func TestBenchmarkWriteRouteUsesFixedTargetAndNoStoreHeaders(t *testing.T) {
205+
redisServer := mustStartMiniRedis(t)
206+
handler := newTestHandler(t, redisServer.Addr())
207+
client := redis.NewClient(&redis.Options{Addr: redisServer.Addr()})
208+
t.Cleanup(func() { _ = client.Close() })
209+
seedBenchmarkNamespace(t, client)
210+
211+
recorder := httptest.NewRecorder()
212+
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/bench/write", nil))
213+
214+
if recorder.Code != http.StatusOK {
215+
t.Fatalf("expected 200, got %d", recorder.Code)
216+
}
217+
218+
if recorder.Header().Get("Access-Control-Allow-Origin") != "*" {
219+
t.Fatalf("expected CORS header, got %q", recorder.Header().Get("Access-Control-Allow-Origin"))
220+
}
221+
222+
if !strings.Contains(recorder.Header().Get("Cache-Control"), "no-store") {
223+
t.Fatalf("expected no-store cache header, got %q", recorder.Header().Get("Cache-Control"))
224+
}
225+
226+
if recorder.Header().Get("Pragma") != "no-cache" {
227+
t.Fatalf("expected pragma no-cache header, got %q", recorder.Header().Get("Pragma"))
228+
}
229+
230+
if recorder.Header().Get("Expires") != "0" {
231+
t.Fatalf("expected expires header 0, got %q", recorder.Header().Get("Expires"))
232+
}
233+
234+
var payload map[string]any
235+
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
236+
t.Fatalf("unmarshal benchmark response: %v", err)
237+
}
238+
239+
if payload["status"] != "success" {
240+
t.Fatalf("expected success envelope, got %#v", payload)
241+
}
242+
243+
data, ok := payload["data"].(map[string]any)
244+
if !ok {
245+
t.Fatalf("expected data envelope, got %#v", payload)
246+
}
247+
248+
for field, want := range map[string]float64{"site_uv": 1, "site_pv": 1, "page_pv": 1} {
249+
if data[field] != want {
250+
t.Fatalf("expected %s=%v, got %#v", field, want, data[field])
251+
}
252+
}
253+
254+
keys, err := client.Keys(context.Background(), "*").Result()
255+
if err != nil {
256+
t.Fatalf("list redis keys: %v", err)
257+
}
258+
259+
for _, key := range keys {
260+
if strings.HasPrefix(key, "ratelimit:") {
261+
continue
262+
}
263+
if !strings.Contains(key, "bench.vercount.one") {
264+
t.Fatalf("expected only benchmark namespace keys, got %q in %#v", key, keys)
265+
}
266+
}
267+
268+
pageValue, err := client.Get(context.Background(), "pv:page:bench.vercount.one:/gurt").Result()
269+
if err != nil {
270+
t.Fatalf("read benchmark page key: %v", err)
271+
}
272+
273+
if pageValue != "1" {
274+
t.Fatalf("expected fixed /gurt page key to increment to 1, got %q", pageValue)
275+
}
276+
}
277+
278+
func TestBenchmarkWriteRouteUsesV2RateLimitErrors(t *testing.T) {
279+
redisServer := mustStartMiniRedis(t)
280+
handler := newTestHandler(t, redisServer.Addr())
281+
client := redis.NewClient(&redis.Options{Addr: redisServer.Addr()})
282+
t.Cleanup(func() { _ = client.Close() })
283+
seedBenchmarkNamespace(t, client)
284+
285+
for i := 0; i < 80; i++ {
286+
recorder := httptest.NewRecorder()
287+
request := httptest.NewRequest(http.MethodGet, "/bench/write", nil)
288+
request.RemoteAddr = "203.0.113.10:1234"
289+
handler.ServeHTTP(recorder, request)
290+
291+
if recorder.Code != http.StatusOK {
292+
t.Fatalf("expected warm-up request to succeed, got %d", recorder.Code)
293+
}
294+
}
295+
296+
recorder := httptest.NewRecorder()
297+
request := httptest.NewRequest(http.MethodGet, "/bench/write", nil)
298+
request.RemoteAddr = "203.0.113.10:1234"
299+
handler.ServeHTTP(recorder, request)
300+
301+
if recorder.Code != http.StatusTooManyRequests {
302+
t.Fatalf("expected 429, got %d", recorder.Code)
303+
}
304+
305+
if !strings.Contains(recorder.Header().Get("Cache-Control"), "no-store") {
306+
t.Fatalf("expected no-store cache header, got %q", recorder.Header().Get("Cache-Control"))
307+
}
308+
309+
if recorder.Header().Get("Pragma") != "no-cache" {
310+
t.Fatalf("expected pragma no-cache header, got %q", recorder.Header().Get("Pragma"))
311+
}
312+
313+
if recorder.Header().Get("Expires") != "0" {
314+
t.Fatalf("expected expires header 0, got %q", recorder.Header().Get("Expires"))
315+
}
316+
317+
var payload map[string]any
318+
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
319+
t.Fatalf("unmarshal rate-limit response: %v", err)
320+
}
321+
322+
if payload["status"] != "error" {
323+
t.Fatalf("expected error envelope, got %#v", payload)
324+
}
325+
326+
if payload["code"] != float64(http.StatusTooManyRequests) {
327+
t.Fatalf("expected 429 code, got %#v", payload["code"])
328+
}
329+
330+
if payload["message"] != "Rate limit exceeded" {
331+
t.Fatalf("expected rate-limit message, got %#v", payload["message"])
332+
}
333+
334+
details, ok := payload["details"].(map[string]any)
335+
if !ok {
336+
t.Fatalf("expected details payload, got %#v", payload)
337+
}
338+
339+
if details["limit"] != float64(80) || details["remaining"] != float64(0) {
340+
t.Fatalf("expected v2 rate-limit details, got %#v", details)
341+
}
342+
}
343+
199344
func TestRequestLoggingEmitsStructuredCompletionEvent(t *testing.T) {
200345
redisServer := mustStartMiniRedis(t)
201346
var logs bytes.Buffer
@@ -282,6 +427,33 @@ func findLogEntry(t *testing.T, output string, event string) map[string]any {
282427
return nil
283428
}
284429

430+
func containsRoute(routes []any, want string) bool {
431+
for _, route := range routes {
432+
if route == want {
433+
return true
434+
}
435+
}
436+
437+
return false
438+
}
439+
440+
func seedBenchmarkNamespace(t *testing.T, client *redis.Client) {
441+
t.Helper()
442+
443+
ctx := context.Background()
444+
seedValues := map[string]string{
445+
"uv:site:count:bench.vercount.one": "0",
446+
"pv:site:bench.vercount.one": "0",
447+
"pv:page:bench.vercount.one:/gurt": "0",
448+
}
449+
450+
for key, value := range seedValues {
451+
if err := client.Set(ctx, key, value, 0).Err(); err != nil {
452+
t.Fatalf("seed %s: %v", key, err)
453+
}
454+
}
455+
}
456+
285457
func mustStartMiniRedis(t *testing.T) *miniredis.Miniredis {
286458
t.Helper()
287459

openspec/changes/api-chi-refactor/.openspec.yaml renamed to openspec/changes/archive/2026-04-20-add-benchmark-endpoints/.openspec.yaml

File renamed without changes.
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
## Context
2+
3+
The Go events service currently exposes public metadata routes in `internal/api/public.go` and counter routes in `internal/api/log.go`. Real browser traffic writes counters through `POST /api/v2/log` with a JSON body, URL validation, rate limiting, Redis-backed UV/PV updates, and a v2 success envelope. External benchmarking tools such as itdog.cn can easily probe `GET` endpoints but cannot reproduce the browser's `POST` body flow, which makes existing public routes a poor fit for measuring real counter write latency from many regions.
4+
5+
The benchmark route needs to stay public, stable, and safe. It should measure the same counter write hot path without polluting customer domains, and it must avoid cache hits that would hide application latency.
6+
7+
## Goals / Non-Goals
8+
9+
**Goals:**
10+
- Expose a public `GET /bench/write` route on the Go events host.
11+
- Reuse the real Redis-backed counter write path so benchmark results reflect counter update latency rather than a no-op handler.
12+
- Keep benchmark traffic isolated under the fixed synthetic tracked URL `https://bench.vercount.one/gurt`.
13+
- Return a stable response contract that is easy to inspect from external benchmark tools.
14+
- Prevent intermediary caches from turning the benchmark route into a static response.
15+
16+
**Non-Goals:**
17+
- Reproduce browser-only behavior such as CORS preflight or JSON body decoding.
18+
- Add a user-configurable benchmark target URL.
19+
- Replace `/api/v1/log` or `/api/v2/log` as the primary embed-facing public API.
20+
- Create a high-volume load-testing endpoint that bypasses the service's normal protections.
21+
22+
## Decisions
23+
24+
### Add a dedicated GET benchmark route that lives with the counter handlers
25+
26+
`GET /bench/write` will be registered as a public route in the Go API and implemented alongside the counter handlers so it can reuse existing validation, write, logging, and response helpers. Keeping the route near `V2Post` avoids duplicating the counter write orchestration and makes the benchmark path a thin facade over the existing write flow.
27+
28+
**Alternatives considered:**
29+
- Put `/bench/write` in `public.go`: rejected because the route is logically a counter write operation and would otherwise need cross-package helper exposure.
30+
- Ask probe tools to call `/api/v2/log` directly: rejected because the target tool only supports simple `GET` probes.
31+
32+
### Use a fixed synthetic target URL and force the full write path
33+
34+
The benchmark route will always target `https://bench.vercount.one/gurt` and will invoke the write flow with deterministic benchmark-owned inputs instead of reading query parameters. The route should execute the full counter write path, including the UV update branch, so the benchmark reflects the heavier real write path instead of a lighter read-mostly variation.
35+
36+
**Alternatives considered:**
37+
- Accept a caller-supplied `url` parameter: rejected because it would mix benchmark traffic into customer namespaces and make results non-repeatable.
38+
- Treat benchmark requests as returning visitors only: rejected because it would skip the fully write-backed UV path and underrepresent the cost of a fresh write.
39+
40+
### Return the v2-style success envelope and disable caching
41+
42+
The benchmark route will return a v2-style success response with the current counter data so the response shape stays familiar and machine-readable. The route will also send no-store cache headers so CDN or intermediary caches do not collapse repeated benchmark requests into a static hit.
43+
44+
**Alternatives considered:**
45+
- Return `204 No Content`: rejected because benchmark tools and manual checks benefit from a visible response payload.
46+
- Return plain v1 JSON: rejected because the benchmark route is modeling the modern public counter surface, which is v2-oriented.
47+
48+
### Reuse the existing request protections and logging middleware
49+
50+
The benchmark route will continue to flow through the existing request logging middleware and rate-limit checks. This keeps the route observable, preserves abuse protection, and keeps the benchmark representative of the public service path instead of introducing a privileged fast lane.
51+
52+
**Alternatives considered:**
53+
- Exempt `/bench/write` from rate limiting: rejected because it increases abuse risk and makes the benchmark less representative of the public service path.
54+
- Add separate benchmark-only logging: rejected because the current request and counter event logging is already sufficient.
55+
56+
## Risks / Trade-offs
57+
58+
- [Synthetic benchmark traffic inflates benchmark namespace counters] → Keep all benchmark writes pinned to `bench.vercount.one/gurt` so no customer-owned domains are affected.
59+
- [GET benchmark results are not a byte-for-byte reproduction of browser POST behavior] → Document that `/bench/write` is a probe-friendly proxy for the real write path, not a full browser simulation.
60+
- [Rate limiting may affect aggressive probe schedules] → Preserve the existing limit for realism and safety, and document that the endpoint is intended for external latency probes rather than sustained load generation.
61+
- [Cache misconfiguration could hide real latency] → Require explicit no-store headers in the benchmark response.
62+
63+
## Migration Plan
64+
65+
1. Add the benchmark route and implementation in the Go events service.
66+
2. Update the public service metadata/documentation to include `/bench/write`.
67+
3. Deploy the Go API and verify repeated benchmark requests hit application logs rather than cache-only responses.
68+
4. Use itdog.cn or similar probes against `/bench/write` to establish baseline regional latency.
69+
70+
Rollback is straightforward: remove the route from the router and documentation. The synthetic benchmark Redis keys can remain because they are isolated from customer traffic and expire under the existing counter TTL rules.
71+
72+
## Open Questions
73+
74+
- None for artifact readiness. The fixed route, target URL, and benchmark intent are now defined well enough to implement.

0 commit comments

Comments
 (0)