Skip to content

add /.export-stats endpoint to dump stats table #169

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 22, 2025
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
10 changes: 9 additions & 1 deletion db.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"time"

_ "modernc.org/sqlite"
"tailscale.com/tstime"
)

// Link is the structure stored for each go short link.
Expand All @@ -42,6 +43,8 @@ func linkID(short string) string {
type SQLiteDB struct {
db *sql.DB
mu sync.RWMutex

clock tstime.Clock // allow overriding time for tests
}

//go:embed schema.sql
Expand All @@ -64,6 +67,11 @@ func NewSQLiteDB(f string) (*SQLiteDB, error) {
return &SQLiteDB{db: db}, nil
}

// Now returns the current time.
func (s *SQLiteDB) Now() time.Time {
return tstime.DefaultClock{Clock: s.clock}.Now()
}

// LoadAll returns all stored Links.
//
// The caller owns the returned values.
Expand Down Expand Up @@ -195,7 +203,7 @@ func (s *SQLiteDB) SaveStats(stats ClickStats) error {
if err != nil {
return err
}
now := time.Now().Unix()
now := s.Now().Unix()
for short, clicks := range stats {
_, err := tx.Exec("INSERT INTO Stats (ID, Created, Clicks) VALUES (?, ?, ?)", linkID(short), now, clicks)
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"-X tailscale.com/version.longStamp=${tsVersion}"
"-X tailscale.com/version.shortStamp=${tsVersion}"
];
vendorHash = "sha256-k3BxPRTgoJM0oCixDVA2k44ztdAUZO4IcO2/QB19HvU="; # SHA based on vendoring go.mod
vendorHash = "sha256-tF3TuIWr5x4inGrykXAjXBQATpDdpTX8HDYmEeukTEc="; # SHA based on vendoring go.mod
};
});

Expand Down
37 changes: 37 additions & 0 deletions golink.go
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,7 @@ func serveHandler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/.detail/", serveDetail)
mux.HandleFunc("/.export", serveExport)
mux.HandleFunc("/.export-stats", serveExportStats)
mux.HandleFunc("/.help", serveHelp)
mux.HandleFunc("/.opensearch", serveOpenSearch)
mux.HandleFunc("/.all", serveAll)
Expand Down Expand Up @@ -990,6 +991,42 @@ func serveExport(w http.ResponseWriter, _ *http.Request) {
}
}

// serveExportStats prints a snapshot of the stats database table.
//
// Stats are printed in CSV format with three columns: link ID, UNIX timestamp, and click count.
// Each stat line represents the number of clicks in the previous minute.
func serveExportStats(w http.ResponseWriter, _ *http.Request) {
if err := flushStats(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

rows, err := db.db.Query("SELECT ID, Created, Clicks FROM Stats ORDER BY Created, ID")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer func() {
rows.Close()
if err := rows.Err(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}()

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the header be added to the output?

Suggested change
fmt.Fprint(w, "link ID, UNIX timestamp, and click count")

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe? The data is pretty simple, and I'm not sure what this is going to get imported into and whether it can easily ignore header rows. I'll probably leave it off for now, and just document it on the help page once we're sure this works how we need. We can always add the header later as well.

for rows.Next() {
var id string
var created int64
var clicks int
err := rows.Scan(&id, &created, &clicks)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// id is not permitted to contain commas, so no need to worry about CSV quoting
fmt.Fprintf(w, "%s,%d,%d\n", id, created, clicks)
}
}

func restoreLastSnapshot() error {
bs := bufio.NewScanner(bytes.NewReader(LastSnapshot))
var restored int
Expand Down
62 changes: 62 additions & 0 deletions golink_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"time"

"golang.org/x/net/xsrftoken"
"tailscale.com/tstest"
"tailscale.com/types/ptr"
"tailscale.com/util/must"
)
Expand Down Expand Up @@ -358,6 +359,67 @@ func TestServeDelete(t *testing.T) {
}
}

func TestServeExport(t *testing.T) {
clock := tstest.NewClock(tstest.ClockOpts{
Start: time.Date(2022, 06, 02, 1, 2, 3, 4, time.UTC),
})

var err error
db, err = NewSQLiteDB(":memory:")
db.clock = clock
if err != nil {
t.Fatal(err)
}
db.Save(&Link{Short: "a", Owner: "a@example.com"})
db.Save(&Link{Short: "foo", Owner: "foo@example.com"})
db.Save(&Link{Short: "link-owned-by-tagged-devices", Long: "/before", Owner: "tagged-devices"})

click := func(id string) {
r := httptest.NewRequest("GET", "/"+id, nil)
w := httptest.NewRecorder()
serveHandler().ServeHTTP(w, r)
}
initStats()
click("a")
click("foo")
click("foo")
flushStats()
clock.Advance(3 * time.Minute)
click("a")

// export links
r := httptest.NewRequest("GET", "/.export", nil)
w := httptest.NewRecorder()
serveHandler().ServeHTTP(w, r)

if want := http.StatusOK; w.Code != want {
t.Errorf("serveExport = %d; want %d", w.Code, want)
}
wantOutput := `{"Short":"a","Long":"","Created":"0001-01-01T00:00:00Z","LastEdit":"0001-01-01T00:00:00Z","Owner":"a@example.com"}
{"Short":"foo","Long":"","Created":"0001-01-01T00:00:00Z","LastEdit":"0001-01-01T00:00:00Z","Owner":"foo@example.com"}
{"Short":"link-owned-by-tagged-devices","Long":"/before","Created":"0001-01-01T00:00:00Z","LastEdit":"0001-01-01T00:00:00Z","Owner":"tagged-devices"}
`
if got := w.Body.String(); got != wantOutput {
t.Errorf("serveExport = %v; want %v", got, wantOutput)
}

// export links stats
r = httptest.NewRequest("GET", "/.export-stats", nil)
w = httptest.NewRecorder()
serveHandler().ServeHTTP(w, r)

if want := http.StatusOK; w.Code != want {
t.Errorf("serveExportStats = %d; want %d", w.Code, want)
}
wantOutput = `a,1654131723,1
foo,1654131723,2
a,1654131903,1
`
if got := w.Body.String(); got != wantOutput {
t.Errorf("serveExportStats = %v; want %v", got, wantOutput)
}
}

func TestReadOnlyMode(t *testing.T) {
var err error
db, err = NewSQLiteDB(":memory:")
Expand Down
Loading