commit
2751a839b9
@ -0,0 +1,127 @@
|
||||
package servertiming
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ServerTiming struct {
|
||||
Name string
|
||||
Dur time.Duration
|
||||
Desc string
|
||||
|
||||
// Used when formatting. The default behavior is to output time as an int
|
||||
// representing milliseconds. Increasing the precision will add decimals to
|
||||
// the output, down to nanoseconds.
|
||||
DecimalPrecision int
|
||||
}
|
||||
|
||||
func (t ServerTiming) String() string {
|
||||
sb := strings.Builder{}
|
||||
sb.WriteString(t.Name)
|
||||
|
||||
if t.Dur != 0 {
|
||||
sb.WriteString(";dur=")
|
||||
// precision is clamped between 0 and 6 inclusively. 0 represents
|
||||
// milliseconds as an integer, 6 decimal positions represent nanoseconds
|
||||
precision := min(max(0, t.DecimalPrecision), 6)
|
||||
sb.WriteString(fmt.Sprintf("%.*f", precision, float64(t.Dur.Nanoseconds())/1_000_000))
|
||||
}
|
||||
|
||||
if t.Desc != "" {
|
||||
sb.WriteString(";desc=")
|
||||
sb.WriteString(t.Desc)
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func FromString(s string) ServerTiming {
|
||||
st := ServerTiming{}
|
||||
|
||||
part, rest, more := strings.Cut(s, ";")
|
||||
st.Name = strings.TrimSpace(part)
|
||||
|
||||
for more {
|
||||
part, rest, more = strings.Cut(strings.TrimSpace(rest), ";")
|
||||
key, val, _ := strings.Cut(part, "=")
|
||||
val = strings.TrimSpace(val)
|
||||
switch key {
|
||||
case "desc":
|
||||
st.Desc = val
|
||||
case "dur":
|
||||
// From the spec:
|
||||
// Since duration is a DOMHighResTimeStamp, it usually represents a
|
||||
// duration in milliseconds. Since this is not enforcable in
|
||||
// practice, duration can represent any unit of time, and having it
|
||||
// represent a duration in milliseconds is a recommendation.
|
||||
|
||||
// The happy path is an int, in which case milliseconds are assumed
|
||||
if i, err := strconv.Atoi(val); err == nil {
|
||||
st.Dur = time.Duration(i * int(time.Millisecond))
|
||||
break
|
||||
}
|
||||
|
||||
// Otherwise we try to parse as a float, and multiply by 1,000,000
|
||||
// to get nanoseconds and truncate the rest
|
||||
if f, err := strconv.ParseFloat(val, 64); err == nil {
|
||||
st.Dur = time.Duration(int(f*1_000_000) * int(time.Nanosecond))
|
||||
}
|
||||
default:
|
||||
// ignore any unknown token
|
||||
}
|
||||
}
|
||||
|
||||
return st
|
||||
}
|
||||
|
||||
func Append(r *http.Response, t ServerTiming) {
|
||||
r.Header.Add("Server-Timing", t.String())
|
||||
}
|
||||
|
||||
func Trailer(r *http.Response, t ServerTiming) {
|
||||
r.Trailer.Add("Server-Timing", t.String())
|
||||
}
|
||||
|
||||
func Parse(r *http.Response) []ServerTiming {
|
||||
res := []ServerTiming{}
|
||||
|
||||
for k, v := range r.Header {
|
||||
if strings.ToLower(k) == "server-timing" {
|
||||
for _, s := range v {
|
||||
for _, single := range strings.Split(s, ",") {
|
||||
res = append(res, FromString(single))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for k, v := range r.Trailer {
|
||||
if strings.ToLower(k) == "server-timing" {
|
||||
for _, s := range v {
|
||||
for _, single := range strings.Split(s, ",") {
|
||||
res = append(res, FromString(single))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
@ -0,0 +1,293 @@
|
||||
package servertiming
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Example taken from the W3C spec
|
||||
// see: https://w3c.github.io/server-timing/#examples
|
||||
//
|
||||
// ```
|
||||
// > GET /resource HTTP/1.1
|
||||
// > Host: example.com
|
||||
//
|
||||
//
|
||||
// < HTTP/1.1 200 OK
|
||||
// < Server-Timing: miss, db;dur=53, app;dur=47.2
|
||||
// < Server-Timing: customView, dc;desc=atl
|
||||
// < Server-Timing: cache;desc="Cache Read";dur=23.2
|
||||
// < Trailer: Server-Timing
|
||||
// < (... snip response body ...)
|
||||
// < Server-Timing: total;dur=123.4
|
||||
// ```
|
||||
//
|
||||
// | Name | Duration | Description |
|
||||
// | ---------- | -------- | ----------- |
|
||||
// | miss | | |
|
||||
// | db | 53 | |
|
||||
// | app | 47.2 | |
|
||||
// | customView | | |
|
||||
// | dc | | atl |
|
||||
// | cache | 23.2 | Cache Read |
|
||||
// | total | 123.4 | |
|
||||
|
||||
func TestServerTiming_String(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
st ServerTiming
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "just name",
|
||||
st: ServerTiming{Name: "miss"},
|
||||
want: "miss",
|
||||
},
|
||||
{
|
||||
name: "name and dur",
|
||||
st: ServerTiming{
|
||||
Name: "db",
|
||||
Dur: 53 * time.Millisecond,
|
||||
},
|
||||
want: "db;dur=53",
|
||||
},
|
||||
{
|
||||
name: "name and decimal dur",
|
||||
st: ServerTiming{
|
||||
Name: "app",
|
||||
Dur: 47_200 * time.Microsecond,
|
||||
DecimalPrecision: 1,
|
||||
},
|
||||
want: "app;dur=47.2",
|
||||
},
|
||||
{
|
||||
name: "name and nanosecond dur",
|
||||
st: ServerTiming{
|
||||
Name: "app",
|
||||
Dur: 47_200 * time.Microsecond,
|
||||
DecimalPrecision: 6,
|
||||
},
|
||||
want: "app;dur=47.200000",
|
||||
},
|
||||
{
|
||||
name: "name and dur, negative precision",
|
||||
st: ServerTiming{
|
||||
Name: "app",
|
||||
Dur: 47_200 * time.Microsecond,
|
||||
DecimalPrecision: -1,
|
||||
},
|
||||
want: "app;dur=47",
|
||||
},
|
||||
{
|
||||
name: "name and dur, out-of-bound precision",
|
||||
st: ServerTiming{
|
||||
Name: "app",
|
||||
Dur: 47_200 * time.Microsecond,
|
||||
DecimalPrecision: 7,
|
||||
},
|
||||
want: "app;dur=47.200000",
|
||||
},
|
||||
{
|
||||
name: "name and desc",
|
||||
st: ServerTiming{
|
||||
Name: "dc",
|
||||
Desc: "atl",
|
||||
},
|
||||
want: "dc;desc=atl",
|
||||
},
|
||||
{
|
||||
name: "name, desc, and dur",
|
||||
st: ServerTiming{
|
||||
Name: "cache",
|
||||
Dur: 23 * time.Millisecond,
|
||||
Desc: "Cache Read",
|
||||
},
|
||||
want: "cache;dur=23;desc=Cache Read",
|
||||
},
|
||||
{
|
||||
name: "name, desc, and decimal dur",
|
||||
st: ServerTiming{
|
||||
Name: "cache",
|
||||
Dur: 23_200 * time.Microsecond,
|
||||
Desc: "Cache Read",
|
||||
DecimalPrecision: 1,
|
||||
},
|
||||
want: "cache;dur=23.2;desc=Cache Read",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.st.String(); got != tt.want {
|
||||
t.Errorf("ServerTiming.String() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
s string
|
||||
want ServerTiming
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
s: "",
|
||||
want: ServerTiming{},
|
||||
},
|
||||
{
|
||||
name: "name only",
|
||||
s: "miss",
|
||||
want: ServerTiming{Name: "miss"},
|
||||
},
|
||||
{
|
||||
name: "name and dur",
|
||||
s: "db;dur=53",
|
||||
want: ServerTiming{
|
||||
Name: "db",
|
||||
Dur: 53 * time.Millisecond,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "name, dur and desc",
|
||||
s: "cache;dur=23;desc=Cache Read",
|
||||
want: ServerTiming{
|
||||
Name: "cache",
|
||||
Dur: 23 * time.Millisecond,
|
||||
Desc: "Cache Read",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "name, desc",
|
||||
s: "cache;desc=Cache Read;dur=23",
|
||||
want: ServerTiming{
|
||||
Name: "cache",
|
||||
Dur: 23 * time.Millisecond,
|
||||
Desc: "Cache Read",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "name, dur and desc with padding",
|
||||
s: "cache ; dur=23 ; desc=Cache Read ",
|
||||
want: ServerTiming{
|
||||
Name: "cache",
|
||||
Dur: 23 * time.Millisecond,
|
||||
Desc: "Cache Read",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "name and decimal dur",
|
||||
s: "cache;dur=23.2",
|
||||
want: ServerTiming{
|
||||
Name: "cache",
|
||||
Dur: 23_200 * time.Microsecond,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := FromString(tt.s); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("FromString() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppend(t *testing.T) {
|
||||
resp := &http.Response{
|
||||
Header: http.Header{},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
st ServerTiming
|
||||
}{
|
||||
{
|
||||
name: "name only",
|
||||
st: ServerTiming{Name: "miss"},
|
||||
},
|
||||
{
|
||||
name: "name and dur",
|
||||
st: ServerTiming{
|
||||
Name: "db",
|
||||
Dur: 53 * time.Millisecond,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "name, dur and desc",
|
||||
st: ServerTiming{
|
||||
Name: "cache",
|
||||
Dur: 23 * time.Millisecond,
|
||||
Desc: "Cache Read",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
Append(resp, tt.st)
|
||||
})
|
||||
}
|
||||
|
||||
vals := resp.Header.Values("Server-Timing")
|
||||
if len(vals) != len(tests) {
|
||||
t.Errorf("Expected %d values in the headers, got %d", len(tests), len(vals))
|
||||
}
|
||||
|
||||
for i, v := range vals {
|
||||
if v != tests[i].st.String() {
|
||||
t.Errorf("Expected '%s', got %s", tests[i].st.String(), v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrailer(t *testing.T) {
|
||||
type args struct {
|
||||
r *http.Response
|
||||
t ServerTiming
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
}{
|
||||
// TODO: Add test cases.
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
Trailer(tt.args.r, tt.args.t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
resp := &http.Response{
|
||||
Header: http.Header{},
|
||||
Trailer: http.Header{},
|
||||
}
|
||||
resp.Header.Add("Server-Timing", "miss, db;dur=53, app;dur=47")
|
||||
resp.Header.Add("Server-Timing", "customView, dc;desc=atl")
|
||||
resp.Header.Add("Server-Timing", `cache;desc="Cache Read";dur=23`)
|
||||
resp.Trailer.Add("Server-Timing", "total;dur=123")
|
||||
|
||||
timings := Parse(resp)
|
||||
if len(timings) != 7 {
|
||||
t.Errorf("Expected 7 timings, got %d", len(timings))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWithDecimal(t *testing.T) {
|
||||
resp := &http.Response{
|
||||
Header: http.Header{},
|
||||
Trailer: http.Header{},
|
||||
}
|
||||
resp.Header.Add("Server-Timing", "miss, db;dur=53, app;dur=47.2")
|
||||
resp.Header.Add("Server-Timing", "customView, dc;desc=atl")
|
||||
resp.Header.Add("Server-Timing", `cache;desc="Cache Read";dur=23.2`)
|
||||
resp.Trailer.Add("Server-Timing", "total;dur=123.4")
|
||||
|
||||
timings := Parse(resp)
|
||||
if len(timings) != 7 {
|
||||
t.Errorf("Expected 7 timings, got %d", len(timings))
|
||||
}
|
||||
}
|
Loading…
Reference in new issue