Initial MVP commit
This commit is contained in:
commit
7bcf16a1d4
24 changed files with 2781 additions and 0 deletions
goatcounter
136
goatcounter/client.go
Normal file
136
goatcounter/client.go
Normal 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
|
||||
}
|
69
goatcounter/multi_site_client.go
Normal file
69
goatcounter/multi_site_client.go
Normal 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
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue