goatcounter-systemd/goatcounter/client.go
2024-09-28 16:20:24 -04:00

136 lines
3.2 KiB
Go

package goatcounter
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"slices"
"time"
"git.0xdad.com/tblyler/goatcounter-systemd/caddy"
)
const (
MaxHitsPerRequest = 500
)
type Hit struct {
Host string `json:"-"`
Bot int `json:"bot,omitempty"`
CreatedAt string `json:"created_at"`
Event bool `json:"event"`
IP string `json:"ip"`
Location string `json:"location,omitempty"`
Path string `json:"path"`
Query string `json:"query,omitempty"`
Referrer string `json:"ref,omitempty"`
Session string `json:"session,omitempty"`
ScreenSize string `json:"size,omitempty"`
Title string `json:"title,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
}
type CountRequest struct {
Filter []string `json:"filter,omitempty"`
Hits []Hit `json:"hits"`
NoSessions bool `json:"no_sessions"`
}
type Client struct {
url *url.URL
apiKey string
httpClient *http.Client
defaultHeaders http.Header
}
func HitFromCaddyLogEntry(logEntry caddy.LogEntry) Hit {
path := ""
query := ""
requestURI, err := url.ParseRequestURI(logEntry.Request.URI)
if err == nil {
path = requestURI.Path
query = requestURI.RawQuery
}
return Hit{
Bot: 0,
Host: logEntry.Request.Host,
CreatedAt: logEntry.Timestamp.Format(time.RFC3339Nano),
Event: false,
IP: logEntry.Request.ClientIP,
Location: "",
Path: path,
Query: query,
Referrer: logEntry.Request.Headers.Get("Referer"),
Session: "",
ScreenSize: "",
Title: "",
UserAgent: logEntry.Request.Headers.Get("User-Agent"),
}
}
func NewClient(address, apiKey string) (*Client, error) {
url, err := url.ParseRequestURI(address)
if err != nil {
return nil, fmt.Errorf("failed to parse address as URL %s: %w", address, err)
}
return &Client{
url: url,
apiKey: apiKey,
httpClient: &http.Client{},
defaultHeaders: http.Header{
"Content-Type": []string{"application/json"},
"Authorization": []string{"Bearer " + apiKey},
},
}, nil
}
func (c *Client) countURL() string {
return c.url.JoinPath("/api/v0/count").String()
}
func (c *Client) URL() *url.URL {
return c.url
}
func (c *Client) Count(ctx context.Context, hits ...Hit) (counted uint64, err error) {
for hits := range slices.Chunk(hits, MaxHitsPerRequest) {
if len(hits) == 0 {
continue
}
body, err := json.Marshal(CountRequest{
NoSessions: true,
Hits: hits,
})
if err != nil {
return counted, fmt.Errorf("failed to marshal count request: %w", err)
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.countURL(), bytes.NewReader(body))
if err != nil {
return counted, fmt.Errorf("failed to create count request: %w", err)
}
request.Header = c.defaultHeaders
response, err := c.httpClient.Do(request)
if err != nil {
return counted, fmt.Errorf("failed to perform count request: %w", err)
}
if response.StatusCode != http.StatusAccepted {
responseBody, _ := io.ReadAll(response.Body)
return counted, fmt.Errorf("unexpected status code %d, response: %s", response.StatusCode, string(responseBody))
}
counted += uint64(len(hits))
}
return counted, nil
}