Initial copy in

This commit is contained in:
Ian Gulliver
2023-06-12 20:03:17 -07:00
parent d832ab35c7
commit 6ff8003f69
8 changed files with 368 additions and 8 deletions

View File

@@ -36,11 +36,3 @@ linters:
- thelper
- varcheck
- varnamelen
linters-settings:
tagliatelle:
case:
use-field-name: true
rules:
json: goCamel
wsl:
allow-separated-leading-comment: true

147
client.go Normal file
View File

@@ -0,0 +1,147 @@
package event
import (
"bytes"
"compress/gzip"
"context"
"encoding/json"
"fmt"
"log"
"strings"
"sync"
"time"
"github.com/go-resty/resty/v2"
)
type Client struct {
targets []*Target
hooks []Hook
mu sync.Mutex
}
type Hook func(context.Context, *Event)
func New() *Client {
return &Client{}
}
func (c *Client) AddTarget(url string, headers map[string]string, writePeriodSeconds float64) *Target {
target := &Target{
client: resty.New().SetBaseURL(url).SetHeaders(headers),
writePeriodSeconds: writePeriodSeconds,
windowSeconds: 100.0,
stop: make(chan bool),
lastEvent: time.Now(),
}
go c.flushLoop(target)
c.mu.Lock()
defer c.mu.Unlock()
c.targets = append(c.targets, target)
return target
}
func (c *Client) AddHook(hook Hook) {
c.mu.Lock()
defer c.mu.Unlock()
c.hooks = append(c.hooks, hook)
}
func (c *Client) Log(ctx context.Context, vals ...any) {
ev := newEvent("log", vals...)
c.writeEvent(ctx, ev)
parts := []string{}
for i := 0; i < len(vals); i += 2 {
parts = append(parts, fmt.Sprintf("%s=%s", vals[i], vals[i+1]))
}
log.Print(strings.Join(parts, " "))
}
func (c *Client) Close() {
c.mu.Lock()
defer c.mu.Unlock()
for _, target := range c.targets {
close(target.stop)
}
}
func (c *Client) writeEvent(ctx context.Context, ev *Event) {
ev.Set("durationMS", time.Since(ev.start).Milliseconds())
c.mu.Lock()
defer c.mu.Unlock()
for _, hook := range c.hooks {
hook(ctx, ev)
}
for _, target := range c.targets {
target.writeEvent(ev)
}
}
func (c *Client) flushLoop(target *Target) {
t := time.NewTicker(time.Duration(target.writePeriodSeconds * float64(time.Second)))
defer t.Stop()
for {
select {
case <-target.stop:
c.flush(target)
return
case <-t.C:
c.flush(target)
}
}
}
func (c *Client) flush(target *Target) {
c.mu.Lock()
events := target.events
target.events = nil
c.mu.Unlock()
if len(events) == 0 {
return
}
buf := &bytes.Buffer{}
g := gzip.NewWriter(buf)
enc := json.NewEncoder(g)
err := enc.Encode(events)
if err != nil {
panic(err)
}
err = g.Close()
if err != nil {
panic(err)
}
resp, err := target.client.R().
SetHeader("Content-Type", "application/json").
SetHeader("Content-Encoding", "gzip").
SetBody(buf).
Post("")
if err != nil {
log.Printf("failed write to event target: %s", err)
return
}
if resp.IsError() {
log.Printf("failed write to event target: %d %s: %s", resp.StatusCode(), resp.Status(), resp.String())
return
}
}

View File

@@ -1 +1,37 @@
package event
import "time"
type Event struct {
start time.Time
Time string `json:"time"`
SampleRate int64 `json:"samplerate"`
Data map[string]any `json:"data"`
}
func newEvent(eventType string, vals ...any) *Event {
now := time.Now()
ev := &Event{
start: now,
Time: now.Format(time.RFC3339Nano),
Data: map[string]any{
"type": eventType,
},
}
ev.Set(vals...)
return ev
}
func (ev *Event) Set(vals ...any) {
if len(vals)%2 != 0 {
panic(vals)
}
for i := 0; i < len(vals); i += 2 {
ev.Data[vals[i].(string)] = vals[i+1]
}
}

4
go.mod
View File

@@ -1,3 +1,7 @@
module github.com/gopatchy/event
go 1.20
require github.com/go-resty/resty/v2 v2.7.0
require golang.org/x/net v0.0.0-20211029224645-99673261e6eb // indirect

9
go.sum Normal file
View File

@@ -0,0 +1,9 @@
github.com/go-resty/resty/v2 v2.7.0 h1:me+K9p3uhSmXtrBZ4k9jcEAfJmuC8IivWHwaLZwPrFY=
github.com/go-resty/resty/v2 v2.7.0/go.mod h1:9PWDzw47qPphMRFfhsyk0NnSgvluHcljSMVIq3w7q0I=
golang.org/x/net v0.0.0-20211029224645-99673261e6eb h1:pirldcYWx7rx7kE5r+9WsOXPXK0+WH5+uZ7uPmJ44uM=
golang.org/x/net v0.0.0-20211029224645-99673261e6eb/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

84
hooks.go Normal file
View File

@@ -0,0 +1,84 @@
package event
import (
"context"
"runtime/debug"
"runtime/metrics"
"strings"
"syscall"
"time"
"unicode"
)
func HookBuildInfo(_ context.Context, ev *Event) {
buildInfo, ok := debug.ReadBuildInfo()
if !ok {
panic("ReadBuildInfo() failed")
}
ev.Set(
"goVersion", buildInfo.GoVersion,
"goPackagePath", buildInfo.Path,
"goMainModuleVersion", buildInfo.Main.Version,
)
}
func HookMetrics(_ context.Context, ev *Event) {
descs := metrics.All()
samples := make([]metrics.Sample, len(descs))
for i := range samples {
samples[i].Name = descs[i].Name
}
metrics.Read(samples)
for _, sample := range samples {
name := convertMetricName(sample.Name)
switch sample.Value.Kind() { //nolint:exhaustive
case metrics.KindUint64:
ev.Set(name, sample.Value.Uint64())
case metrics.KindFloat64:
ev.Set(name, sample.Value.Float64())
}
}
}
func HookRUsage(_ context.Context, ev *Event) {
rusage := &syscall.Rusage{}
err := syscall.Getrusage(syscall.RUSAGE_SELF, rusage)
if err != nil {
panic(err)
}
ev.Set(
"rUsageUTime", time.Duration(rusage.Utime.Nano()).Seconds(),
"rUsageSTime", time.Duration(rusage.Stime.Nano()).Seconds(),
)
}
func convertMetricName(in string) string {
upperNext := false
in = strings.TrimLeft(in, "/")
ret := strings.Builder{}
for _, r := range in {
if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
upperNext = true
continue
}
if upperNext {
r = unicode.ToUpper(r)
upperNext = false
}
ret.WriteRune(r)
}
return ret.String()
}

18
rateclass.go Normal file
View File

@@ -0,0 +1,18 @@
package event
type rateClass struct {
grantRate float64
criteria map[string]any
eventRate float64
}
func (rc *rateClass) match(ev *Event) bool {
for k, v := range rc.criteria {
if ev.Data[k] != v {
return false
}
}
return true
}

70
target.go Normal file
View File

@@ -0,0 +1,70 @@
package event
import (
"math"
"math/rand"
"time"
"github.com/go-resty/resty/v2"
)
type Target struct {
client *resty.Client
writePeriodSeconds float64
windowSeconds float64
rateClasses []*rateClass
stop chan bool
lastEvent time.Time
events []*Event
}
func (target *Target) AddRateClass(grantRate float64, vals ...any) {
if len(vals)%2 != 0 {
panic(vals)
}
erc := &rateClass{
grantRate: grantRate * target.windowSeconds,
criteria: map[string]any{},
}
for i := 0; i < len(vals); i += 2 {
erc.criteria[vals[i].(string)] = vals[i+1]
}
target.rateClasses = append(target.rateClasses, erc)
}
func (target *Target) writeEvent(ev *Event) {
now := time.Now()
secondsSinceLastEvent := now.Sub(target.lastEvent).Seconds()
target.lastEvent = now
// Example:
// windowSeconds = 100
// secondsSinceLastEvent = 25
// eventRateMultiplier = 0.75
eventRateMultiplier := (target.windowSeconds - secondsSinceLastEvent) / target.windowSeconds
maxProb := 0.0
for _, erc := range target.rateClasses {
if !erc.match(ev) {
continue
}
erc.eventRate++
erc.eventRate *= eventRateMultiplier
classProb := erc.grantRate / erc.eventRate
maxProb = math.Max(maxProb, classProb)
}
if maxProb <= 0.0 || rand.Float64() > maxProb { //nolint:gosec
return
}
ev2 := *ev
ev2.SampleRate = int64(math.Max(math.Round(1.0/maxProb), 1.0))
target.events = append(target.events, &ev2)
}