Initial MVP commit

This commit is contained in:
Tony Blyler 2024-09-28 16:20:24 -04:00
commit 7bcf16a1d4
No known key found for this signature in database
24 changed files with 2781 additions and 0 deletions

136
goatcounter/client.go Normal file
View file

@ -0,0 +1,136 @@
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
}

View file

@ -0,0 +1,69 @@
package goatcounter
import (
"context"
"log/slog"
"net/url"
)
type SiteClient interface {
URL() *url.URL
Count(context.Context, ...Hit) (uint64, error)
}
type MultiSiteClient struct {
siteToClient map[string]SiteClient
ignoreSites map[string]struct{}
}
func NewMultiSiteClient(siteToClient map[string]SiteClient, ignoreGoatSites bool) *MultiSiteClient {
ignoreSites := map[string]struct{}{}
if ignoreGoatSites {
for _, client := range siteToClient {
ignoreSite := client.URL().Host
slog.Debug("ignoring goat site", slog.String("site", ignoreSite))
ignoreSites[ignoreSite] = struct{}{}
}
}
return &MultiSiteClient{
siteToClient: siteToClient,
ignoreSites: ignoreSites,
}
}
func (m *MultiSiteClient) Count(ctx context.Context, hits ...Hit) (counted uint64, err error) {
siteHits := map[string][]Hit{}
for _, hit := range hits {
siteHits[hit.Host] = append(siteHits[hit.Host], hit)
}
for site, hits := range siteHits {
hitCount := len(hits)
logger := slog.With(slog.String("site", site), slog.Int("count", hitCount))
if _, ignore := m.ignoreSites[site]; ignore {
logger.DebugContext(ctx, "ignoring hits for site")
continue
}
client, ok := m.siteToClient[site]
if !ok {
logger.ErrorContext(ctx, "no client for site, skipping")
continue
}
subCount, err := client.Count(ctx, hits...)
if err != nil {
logger.ErrorContext(ctx, "failed to count hits", slog.String("err", err.Error()))
continue
}
logger.InfoContext(ctx, "counted hits", slog.Uint64("counted", subCount))
counted += subCount
}
return counted, nil
}