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: 2 additions & 1 deletion PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ Confirmed functionality:

Technical constraints:

- Retention is 4 hours; a cron sweep runs every 5 minutes.
- Retention is 4 hours; a sweep runs at startup and every 5 minutes after. An
open page drops captures from its own list as they age out.
- Storage is SQLite on the container's writable layer. Capture history is lost
on restart, by design. No durable store, no migration path.
- Request body limit is 1 MiB.
Expand Down
29 changes: 28 additions & 1 deletion public/endpoint.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,23 @@
})
.catch((err) => console.error(err));
},

// Nothing tells the page that the server swept a capture out from under
// it, so a list left open long enough renders requests that no longer
// exist, next to the promise that they were deleted. Dropping them
// locally is only the immediate half: the list is windowed, so the
// refetch is what resyncs `total` and pulls any older-but-live capture
// into the window.
pruneExpired(retentionMs) {
if (!retentionMs) return;
const cutoff = Date.now() - retentionMs;
const live = this.requests.filter(
(r) => r.createdAt.getTime() > cutoff,
);
if (live.length === this.requests.length) return;
this.requests = live;
return this.fetchRequests();
},
});
});

Expand Down Expand Up @@ -154,9 +171,19 @@
if (!endpointId) return;
const scheme = location.protocol === "https:" ? "wss:" : "ws:";
this._wsUrl = `${scheme}//${location.host}/ws/${endpointId}`;
// A page served without the window keeps every capture it was given
// rather than expiring them against a guess.
const retentionSeconds = Number(this.$el.dataset.retentionSeconds);
this._retentionMs =
Number.isFinite(retentionSeconds) && retentionSeconds > 0
? retentionSeconds * 1000
: 0;
Alpine.store("main").setEndpoint(endpointId);
this._connectWebSocket();
setInterval(() => this.tick++, TICK_MS);
setInterval(() => {
this.tick++;
Alpine.store("main").pruneExpired(this._retentionMs);
}, TICK_MS);
document.addEventListener("visibilitychange", () => {
if (!document.hidden) this._clearUnread();
});
Expand Down
21 changes: 15 additions & 6 deletions src/application.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,14 +122,23 @@ func newApplication(config applicationConfig) *fiber.App {
return application
}

// startRetentionSweep drops captures older than the retention window on a
// schedule. It returns the stopped-on-exit scheduler so the caller owns its
// lifetime.
// sweepRetention drops every capture older than the retention window.
func sweepRetention() {
database.DeleteOldRequests(context.Background(), time.Now().Add(-retentionWindow))
}

// startRetentionSweep sweeps once and then on a schedule. The returned
// scheduler runs for the life of the process; there is no shutdown path that
// stops it.
//
// The sweep at startup is what makes the window hold: cron's first tick is a
// full interval away, so a process that restarts more often than the interval
// would otherwise never sweep at all and captures would outlive the window for
// as long as the database file does.
func startRetentionSweep() *cron.Cron {
sweepRetention()
scheduler := cron.New()
if _, err := scheduler.AddFunc(retentionSweep, func() {
database.DeleteOldRequests(context.Background(), time.Now().Add(-retentionWindow))
}); err != nil {
if _, err := scheduler.AddFunc(retentionSweep, sweepRetention); err != nil {
slog.Error("cron job registration failed", "err", err)
os.Exit(1)
}
Expand Down
54 changes: 54 additions & 0 deletions src/application_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package main

import (
"context"
"testing"
"time"

"github.com/stretchr/testify/assert"

"httphq/src/database"
)

// storeCapture writes a capture for an endpoint at a chosen age. GORM only
// stamps CreatedAt when it is zero, so passing one keeps it.
func storeCapture(t *testing.T, endpointID, uuid string, createdAt time.Time) {
t.Helper()
database.CreateRequest(t.Context(), &database.Request{
UUID: uuid,
EndpointID: endpointID,
Method: "POST",
Path: "/",
CreatedAt: createdAt,
})
}

func uuidsFor(ctx context.Context, endpointID string) []string {
var uuids []string
for _, request := range database.GetRequestsForEndpointID(ctx, endpointID, "", 10) {
uuids = append(uuids, request.UUID)
}
return uuids
}

func TestSweepRetention(t *testing.T) {
t.Run("drops captures older than the retention window", func(t *testing.T) {
endpointID := "sweep-old"
storeCapture(t, endpointID, "sweep-old-expired",
time.Now().Add(-retentionWindow).Add(-time.Minute))

sweepRetention()

assert.Empty(t, uuidsFor(t.Context(), endpointID))
})

t.Run("keeps captures inside the retention window", func(t *testing.T) {
endpointID := "sweep-recent"
storeCapture(t, endpointID, "sweep-recent-live",
time.Now().Add(-retentionWindow).Add(time.Minute))

sweepRetention()

assert.Equal(t, []string{"sweep-recent-live"}, uuidsFor(t.Context(), endpointID))
})
}
3 changes: 3 additions & 0 deletions src/pages.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ func renderEndpoint(c fiber.Ctx) error {
"EndpointID": endpointID,
"EndpointURL": endpointURL,
"EndpointWebSocketURL": websocketURL,
// The page drops captures from its own list once they age out, so it
// needs the window as a number rather than as the prose it renders.
"RetentionSeconds": int(retentionWindow.Seconds()),
})
}

Expand Down
9 changes: 9 additions & 0 deletions src/routes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,15 @@ func TestPageRoutes(t *testing.T) {
assert.Contains(t, body, "endpoint.js?v=")
})

// The page expires captures out of its own list, so it needs the window as
// a number. Rendering it from the same constant the sweep uses is what keeps
// the two from drifting.
t.Run("an endpoint page carries the retention window", func(t *testing.T) {
body := bodyOf(t, get(t, "/"+endpointID(t)))

assert.Contains(t, body, `data-retention-seconds="14400"`)
})

// robots.txt excludes endpoint pages, so a canonical URL pointing them at a
// shared address would be a claim nothing else in the site makes.
t.Run("an endpoint page carries no canonical URL", func(t *testing.T) {
Expand Down
1 change: 1 addition & 0 deletions src/views/endpoint.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
class="flex-1 pb-12"
x-data="endpointPage"
data-endpoint-id="{{.EndpointID}}"
data-retention-seconds="{{.RetentionSeconds}}"
>
<h1 class="sr-only">Captured requests for {{.EndpointID}}</h1>

Expand Down
Loading