Links can now change where they send people over time and by audience, safety sees every place a link can route to, and a failed deploy now actually rolls back.
New link options, off by default. These ship behind feature flags and stay off until a flag document enables them, so self-hosters can turn them on today.
- A/B splits (
ab_variants): split a link's traffic across several destinations by weight. Stats gain avariantdimension, so every click is attributed to the destination that served it. Seed the flag withscripts/seed_ab_testing_flag.js. - Scheduled links (
starts_at,pre_start_url): a link goes live at a set time and sends early visitors to an optional page until then. Datetimes are normalised before comparing, so offsets and naive values behave the same. - Expired-link fallback (
expired_redirect_url): once a link expires by time or by click limit, visitors land on a destination you choose instead of the expired page.
Safety sees every destination. A link's geo rules, A/B variants and pre-start page are now stamped as secondary destinations and indexed, so enforcement, feed-delta sweeps and the creation-burst counters judge all of them, not only long_url. A block on a destination now reaches links that only route there through a rule.
The shortener gate stops over-matching. The "links to other URL shorteners" check matched on the registrable domain, so listing goo.gl would also have refused Google Maps and Photos share links on maps.app.goo.gl. A subdomain listed as a redirector now passes, while the shortener's own host is always refused. The shortener list gains 30 entries, including pay-per-click shorteners, IP loggers and both Linkvertise domains. The gate is still off unless SAFETY_SHORTENERS_ENABLED=true.
Self-link check scoped to the host. A URL that merely mentioned spoo.me in its path or query, such as an analytics dashboard filtered on spoo.me, was rejected as a redirect loop. Only a destination whose host is spoo.me, or a subdomain of it, counts now. A malformed authority like https://[::1 returns a clean rejection instead of a 500.
Click storage packs properly. Click documents no longer store empty analytics fields as explicit nulls. A null following a string closed time-series buckets early, which left about four clicks per bucket and a bucket index half the size of every other index combined. New deployments create the clicks collection with hourly granularity. Existing deployments can switch with a one-time collMod, which only goes coarser.
Webhook delivery telemetry. The worker reports stream lag, pending depth and delivery latency, with an Axiom dashboard and monitors for consumer lag and disabled endpoints.
Deploys roll back for real. The rollback step rewrote the image tag but compose kept using the new tag from the shell, so a failed deploy never actually reverted. It does now, and startup gets more time before the health gate gives up.
Repository layout. Deploy config moved to deploy/ (Caddy, Vector, Axiom, and the prod, beta and edge-dev compose files), k6 load tests to tests/k6/, GeoIP databases to data/geoip/, and robots.txt, security.txt, humans.txt and sitemap.xml to static/ at the same URLs. If you set GEOIP_COUNTRY_DB or GEOIP_CITY_DB to a misc/ path, point them at data/geoip/. The README was rewritten, and the formatting check now reports on every pull request.