As a software engineer, it’s rare to have an idea for a personal project and actually finish it. We have ideas all the time, buy domains for them, and then never finish them. For me, I’ve had a brain block for coding that isn’t work for like 5 years. Well, maybe that isn’t completely true, but for the purposes of this post, let’s just roll with it.
At work, I’ve been working a lot with Gatsby on a new site. One of our big metrics that we are tracking with this new site we are building is Google Page Speed Insights. For those that don’t know, Page Speed Insights is a like a special version of Google Lighthouse that runs outside of Chrome’s Dev Tools. It was during one of these runs that I got an idea:
What if I made a GitHub badge so that I can check on this passively?
I did some Googling and couldn’t find any implementations of it and went to work.
Implementation
Page Speed Insights provides an API endpoint in the Go Google client which is easy enough to use. I went ahead are wired it into a Gin HTTP server (I probably could have done this using the stdlib, but I like Gin’s routing and middlewares). After I ran the Page Speed test, I reverse proxied to shields.io, which is a service that can generate GitHub shield badges.
This took me an evening and I was able to fetch a Page Speed badge using the following Markdown.
![eligundry.com Mobile Page Speed Insights](https://page-speed-shield.eligundry.com/mobile/https://eligundry.com)
![eligundry.com Desktop Page Speed Insights](https://page-speed-shield.eligundry.com/desktop/https://eligundry.com)
Look at these badges!
Looks pretty snazzy, though it takes around 30 seconds to run, time to add it to a README!
Results
Ah man, it failed to load! I did some Googling and it turns out that GitHub proxies all images through their camo service which has a 4 second timeout. This is good for the end user (prevents tracking pixels on GitHub and images taking forever to load is bad UX), but terrible for my use case.
🤔 What if I cache it server side? The first Page Speed image load will take forever, but after that, we should be golden for the cache lifetime. I added Gin’s caching middleware and deployed. Sure enough, the first touch penalty was around 30 seconds but after that it loaded instantly! Unfortunately, GitHub’s camo proxy does it’s own caching (so that it can be on a CDN close to your geographic location) and it’s impossible for the second image load to work as outlined 😭.
Conclusion
Even though this is an impossible project to get working as I envisioned, I’m really happy with how it turned out! The code is pretty “clean” (though that is subjective and I cringed saying that).
package main
import (
"context"
"fmt"
"log"
"net/http"
"net/http/httputil"
"os"
"strings"
"time"
"github.com/gin-contrib/cache"
"github.com/gin-contrib/cache/persistence"
ginzap "github.com/gin-contrib/zap"
"github.com/gin-gonic/gin"
servertiming "github.com/p768lwy3/gin-server-timing"
"go.uber.org/zap"
"google.golang.org/api/option"
"google.golang.org/api/pagespeedonline/v5"
)
var (
pageSpeedSvc *pagespeedonline.Service
logger *zap.Logger
store persistence.CacheStore
)
var validInsightTypes = map[string]bool{
"mobile": true,
"desktop": true,
}
func constructShield(score int64) string {
color := "red"
switch {
case score >= 90:
color = "green"
case score >= 80 && score < 90:
color = "orange"
case score >= 70 && score < 80:
color = "yellow"
}
return fmt.Sprintf("Page Speed Insights-%d-%s", score, color)
}
func runPageSpeed(ctx context.Context, url string, insightType string) (int64, error) {
res, err := pageSpeedSvc.Pagespeedapi.Runpagespeed(url).
Strategy(strings.ToUpper(insightType)).
Category("PERFORMANCE").
Context(ctx).
Do()
if err != nil {
return 0, err
}
score := int64(res.LighthouseResult.Categories.Performance.Score.(float64) * 100)
return score, nil
}
func handler(c *gin.Context) {
timing := servertiming.FromContext(c)
targetURL := c.Param("url")[1:]
insightType := c.Param("insightType")
if _, ok := validInsightTypes[insightType]; !ok {
c.JSON(http.StatusBadRequest, gin.H{
"error": "insightType must be either mobile or desktop",
})
return
}
pagespeedTiming := timing.NewMetric("pageSpeed").
WithDesc("Google Page Speed Insights API").
Start()
score, err := runPageSpeed(
c.Request.Context(),
targetURL,
insightType,
)
pagespeedTiming.Stop()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}
proxy := &httputil.ReverseProxy{
Director: func(req *http.Request) {
req.URL.Scheme = "https"
req.URL.Host = "img.shields.io"
req.Host = "img.shields.io"
req.URL.Path = "/badge/" + constructShield(score)
},
ModifyResponse: func(resp *http.Response) error {
resp.Header.Set("Cache-Control", "public, max-age=86400")
return nil
},
}
servertiming.WriteHeader(c)
proxy.ServeHTTP(c.Writer, c.Request)
}
func init() {
var err error
pageSpeedSvc, err = pagespeedonline.NewService(
context.Background(),
option.WithAPIKey(os.Getenv("GOOGLE_API_KEY")),
)
if err != nil {
log.Panicf("could not construct page speed service, %s", err)
}
logger, err = zap.NewProduction()
if err != nil {
log.Panicf("could not construct logger, %s", err)
}
store = persistence.NewInMemoryStore(time.Second)
}
func main() {
router := gin.New()
// middlewares
router.Use(ginzap.Ginzap(logger, time.RFC3339, true))
router.Use(ginzap.RecoveryWithZap(logger, true))
router.Use(servertiming.Middleware())
router.GET("/:insightType/*url", cache.CachePage(store, time.Hour*24, handler))
router.Run()
}
One really cool thing I found while building this was this server timing Gin middleware for writing
Server-Timing
headers to a response. This allowed me to see how long Page Speed Insights was taking
in the Chrome Dev Tools!
It’s like having a mini-DataDog right in your browser!