chore: move the go code into a subfolder

This commit is contained in:
Florian Forster
2025-08-05 15:20:32 -07:00
parent 4ad22ba456
commit cd2921de26
2978 changed files with 373 additions and 300 deletions

View File

@@ -0,0 +1,14 @@
package logstore
type Configs struct {
Access *Config
Execution *Config
}
type Config struct {
Stdout *StdConfig
}
type StdConfig struct {
Enabled bool
}

View File

@@ -0,0 +1,90 @@
package logstore
import (
"context"
"sync"
"time"
"github.com/benbjohnson/clock"
"github.com/zitadel/logging"
)
type bulkSink[T LogRecord[T]] interface {
SendBulk(ctx context.Context, bulk []T) error
}
type bulkSinkFunc[T LogRecord[T]] func(ctx context.Context, bulk []T) error
func (s bulkSinkFunc[T]) SendBulk(ctx context.Context, bulk []T) error {
return s(ctx, bulk)
}
type debouncer[T LogRecord[T]] struct {
// Storing context.Context in a struct is generally bad practice
// https://go.dev/blog/context-and-structs
// However, debouncer starts a go routine that triggers side effects itself.
// So, there is no incoming context.Context available when these events trigger.
// The only context we can use for the side effects is the app context.
// Because this can be cancelled by os signals, it's the better solution than creating new background contexts.
binarySignaledCtx context.Context
clock clock.Clock
ticker *clock.Ticker
mux sync.Mutex
cfg DebouncerConfig
storage bulkSink[T]
cache []T
cacheLen uint
}
type DebouncerConfig struct {
MinFrequency time.Duration
MaxBulkSize uint
}
func newDebouncer[T LogRecord[T]](binarySignaledCtx context.Context, cfg DebouncerConfig, clock clock.Clock, ship bulkSink[T]) *debouncer[T] {
a := &debouncer[T]{
binarySignaledCtx: binarySignaledCtx,
clock: clock,
cfg: cfg,
storage: ship,
}
if cfg.MinFrequency > 0 {
a.ticker = clock.Ticker(cfg.MinFrequency)
go a.shipOnTicks()
}
return a
}
func (d *debouncer[T]) add(item T) {
d.mux.Lock()
defer d.mux.Unlock()
d.cache = append(d.cache, item)
d.cacheLen++
if d.cfg.MaxBulkSize > 0 && d.cacheLen >= d.cfg.MaxBulkSize {
// Add should not block and release the lock
go d.ship()
}
}
func (d *debouncer[T]) ship() {
if d.cacheLen == 0 {
return
}
d.mux.Lock()
defer d.mux.Unlock()
if err := d.storage.SendBulk(d.binarySignaledCtx, d.cache); err != nil {
logging.WithError(err).WithField("size", len(d.cache)).Error("storing bulk failed")
}
d.cache = nil
d.cacheLen = 0
if d.cfg.MinFrequency > 0 {
d.ticker.Reset(d.cfg.MinFrequency)
}
}
func (d *debouncer[T]) shipOnTicks() {
for range d.ticker.C {
d.ship()
}
}

View File

@@ -0,0 +1,84 @@
package logstore
import (
"context"
"time"
"github.com/benbjohnson/clock"
)
type EmitterConfig struct {
Enabled bool
Debounce *DebouncerConfig
}
type emitter[T LogRecord[T]] struct {
enabled bool
ctx context.Context
debouncer *debouncer[T]
emitter LogEmitter[T]
clock clock.Clock
}
type LogRecord[T any] interface {
Normalize() T
}
type LogRecordFunc[T any] func() T
func (r LogRecordFunc[T]) Normalize() T {
return r()
}
type LogEmitter[T LogRecord[T]] interface {
Emit(ctx context.Context, bulk []T) error
}
type LogEmitterFunc[T LogRecord[T]] func(ctx context.Context, bulk []T) error
func (l LogEmitterFunc[T]) Emit(ctx context.Context, bulk []T) error {
return l(ctx, bulk)
}
type LogCleanupper[T LogRecord[T]] interface {
Cleanup(ctx context.Context, keep time.Duration) error
LogEmitter[T]
}
// NewEmitter accepts Clock from github.com/benbjohnson/clock so we can control timers and tickers in the unit tests
func NewEmitter[T LogRecord[T]](ctx context.Context, clock clock.Clock, cfg *EmitterConfig, logger LogEmitter[T]) (*emitter[T], error) {
svc := &emitter[T]{
enabled: cfg != nil && cfg.Enabled,
ctx: ctx,
emitter: logger,
clock: clock,
}
if !svc.enabled {
return svc, nil
}
if cfg.Debounce != nil && (cfg.Debounce.MinFrequency > 0 || cfg.Debounce.MaxBulkSize > 0) {
svc.debouncer = newDebouncer[T](ctx, *cfg.Debounce, clock, newStorageBulkSink(svc.emitter))
}
return svc, nil
}
func (s *emitter[T]) Emit(ctx context.Context, record T) (err error) {
if !s.enabled {
return nil
}
if s.debouncer != nil {
s.debouncer.add(record)
return nil
}
return s.emitter.Emit(ctx, []T{record})
}
func newStorageBulkSink[T LogRecord[T]](emitter LogEmitter[T]) bulkSinkFunc[T] {
return func(ctx context.Context, bulk []T) error {
return emitter.Emit(ctx, bulk)
}
}

View File

@@ -0,0 +1,93 @@
package access
import (
"context"
"database/sql"
"errors"
"time"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/logstore"
"github.com/zitadel/zitadel/internal/logstore/record"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/query/projection"
"github.com/zitadel/zitadel/internal/repository/quota"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
)
var _ logstore.UsageStorer[*record.AccessLog] = (*databaseLogStorage)(nil)
type databaseLogStorage struct {
dbClient *database.DB
commands *command.Commands
queries *query.Queries
}
func NewDatabaseLogStorage(dbClient *database.DB, commands *command.Commands, queries *query.Queries) *databaseLogStorage {
return &databaseLogStorage{dbClient: dbClient, commands: commands, queries: queries}
}
func (l *databaseLogStorage) QuotaUnit() quota.Unit {
return quota.RequestsAllAuthenticated
}
func (l *databaseLogStorage) Emit(ctx context.Context, bulk []*record.AccessLog) error {
if len(bulk) == 0 {
return nil
}
return l.incrementUsage(ctx, bulk)
}
func (l *databaseLogStorage) incrementUsage(ctx context.Context, bulk []*record.AccessLog) (err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
byInstance := make(map[string][]*record.AccessLog)
for _, r := range bulk {
if r.InstanceID != "" {
byInstance[r.InstanceID] = append(byInstance[r.InstanceID], r)
}
}
for instanceID, instanceBulk := range byInstance {
q, getQuotaErr := l.queries.GetQuota(ctx, instanceID, quota.RequestsAllAuthenticated)
if errors.Is(getQuotaErr, sql.ErrNoRows) {
continue
}
err = errors.Join(err, getQuotaErr)
if getQuotaErr != nil {
continue
}
sum, incrementErr := l.incrementUsageFromAccessLogs(ctx, instanceID, q.CurrentPeriodStart, instanceBulk)
err = errors.Join(err, incrementErr)
if incrementErr != nil {
continue
}
notifications, getNotificationErr := l.queries.GetDueQuotaNotifications(ctx, instanceID, quota.RequestsAllAuthenticated, q, q.CurrentPeriodStart, sum)
err = errors.Join(err, getNotificationErr)
if getNotificationErr != nil || len(notifications) == 0 {
continue
}
ctx = authz.WithInstanceID(ctx, instanceID)
reportErr := l.commands.ReportQuotaUsage(ctx, notifications)
err = errors.Join(err, reportErr)
if reportErr != nil {
continue
}
}
return err
}
func (l *databaseLogStorage) incrementUsageFromAccessLogs(ctx context.Context, instanceID string, periodStart time.Time, records []*record.AccessLog) (sum uint64, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
var count uint64
for _, r := range records {
if r.IsAuthenticated() {
count++
}
}
return projection.QuotaProjection.IncrementUsage(ctx, quota.RequestsAllAuthenticated, instanceID, periodStart, count)
}

View File

@@ -0,0 +1,86 @@
package execution
import (
"context"
"database/sql"
"errors"
"math"
"time"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/logstore"
"github.com/zitadel/zitadel/internal/logstore/record"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/query/projection"
"github.com/zitadel/zitadel/internal/repository/quota"
)
var _ logstore.UsageStorer[*record.ExecutionLog] = (*databaseLogStorage)(nil)
type databaseLogStorage struct {
dbClient *database.DB
commands *command.Commands
queries *query.Queries
}
func NewDatabaseLogStorage(dbClient *database.DB, commands *command.Commands, queries *query.Queries) *databaseLogStorage {
return &databaseLogStorage{dbClient: dbClient, commands: commands, queries: queries}
}
func (l *databaseLogStorage) QuotaUnit() quota.Unit {
return quota.ActionsAllRunsSeconds
}
func (l *databaseLogStorage) Emit(ctx context.Context, bulk []*record.ExecutionLog) error {
if len(bulk) == 0 {
return nil
}
return l.incrementUsage(ctx, bulk)
}
func (l *databaseLogStorage) incrementUsage(ctx context.Context, bulk []*record.ExecutionLog) (err error) {
byInstance := make(map[string][]*record.ExecutionLog)
for _, r := range bulk {
if r.InstanceID != "" {
byInstance[r.InstanceID] = append(byInstance[r.InstanceID], r)
}
}
for instanceID, instanceBulk := range byInstance {
q, getQuotaErr := l.queries.GetQuota(ctx, instanceID, quota.ActionsAllRunsSeconds)
if errors.Is(getQuotaErr, sql.ErrNoRows) {
continue
}
err = errors.Join(err, getQuotaErr)
if getQuotaErr != nil {
continue
}
sum, incrementErr := l.incrementUsageFromExecutionLogs(ctx, instanceID, q.CurrentPeriodStart, instanceBulk)
err = errors.Join(err, incrementErr)
if incrementErr != nil {
continue
}
notifications, getNotificationErr := l.queries.GetDueQuotaNotifications(ctx, instanceID, quota.ActionsAllRunsSeconds, q, q.CurrentPeriodStart, sum)
err = errors.Join(err, getNotificationErr)
if getNotificationErr != nil || len(notifications) == 0 {
continue
}
ctx = authz.WithInstanceID(ctx, instanceID)
reportErr := l.commands.ReportQuotaUsage(ctx, notifications)
err = errors.Join(err, reportErr)
if reportErr != nil {
continue
}
}
return err
}
func (l *databaseLogStorage) incrementUsageFromExecutionLogs(ctx context.Context, instanceID string, periodStart time.Time, records []*record.ExecutionLog) (sum uint64, err error) {
var total time.Duration
for _, r := range records {
total += r.Took
}
return projection.QuotaProjection.IncrementUsage(ctx, quota.ActionsAllRunsSeconds, instanceID, periodStart, uint64(math.Floor(total.Seconds())))
}

View File

@@ -0,0 +1,23 @@
package stdout
import (
"context"
"encoding/json"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/logstore"
)
func NewStdoutEmitter[T logstore.LogRecord[T]]() logstore.LogEmitter[T] {
return logstore.LogEmitterFunc[T](func(ctx context.Context, bulk []T) error {
for idx := range bulk {
bytes, err := json.Marshal(bulk[idx])
if err != nil {
return err
}
logging.WithFields("record", string(bytes)).Info("log record emitted")
}
return nil
})
}

View File

@@ -0,0 +1,74 @@
package logstore_test
import (
"time"
"github.com/zitadel/zitadel/internal/logstore"
"github.com/zitadel/zitadel/internal/query"
)
type emitterOption func(config *logstore.EmitterConfig)
func emitterConfig(options ...emitterOption) *logstore.EmitterConfig {
cfg := &logstore.EmitterConfig{
Enabled: true,
Debounce: &logstore.DebouncerConfig{
MinFrequency: 0,
MaxBulkSize: 0,
},
}
for _, opt := range options {
opt(cfg)
}
return cfg
}
func withDebouncerConfig(config *logstore.DebouncerConfig) emitterOption {
return func(c *logstore.EmitterConfig) {
c.Debounce = config
}
}
func withDisabled() emitterOption {
return func(c *logstore.EmitterConfig) {
c.Enabled = false
}
}
type quotaOption func(config *query.Quota)
func quotaConfig(quotaOptions ...quotaOption) *query.Quota {
q := &query.Quota{
Amount: 90,
Limit: false,
ResetInterval: 90 * time.Second,
From: time.Unix(0, 0),
}
for _, opt := range quotaOptions {
opt(q)
}
return q
}
func withAmountAndInterval(n uint64) quotaOption {
return func(c *query.Quota) {
c.Amount = n
c.ResetInterval = time.Duration(n) * time.Second
}
}
func withLimiting() quotaOption {
return func(c *query.Quota) {
c.Limit = true
}
}
func repeat(value, times int) []int {
ints := make([]int, times)
for i := 0; i < times; i++ {
ints[i] = value
}
return ints
}
func uint64Ptr(n uint64) *uint64 { return &n }

View File

@@ -0,0 +1,120 @@
package mock
import (
"context"
"sync"
"time"
"github.com/benbjohnson/clock"
"github.com/zitadel/zitadel/internal/logstore"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/repository/quota"
)
var _ logstore.UsageStorer[*Record] = (*InmemLogStorage)(nil)
var _ logstore.LogCleanupper[*Record] = (*InmemLogStorage)(nil)
var _ logstore.Queries = (*InmemLogStorage)(nil)
type InmemLogStorage struct {
mux sync.Mutex
clock clock.Clock
emitted []*Record
bulks []int
quota *query.Quota
}
func NewInMemoryStorage(clock clock.Clock, quota *query.Quota) *InmemLogStorage {
return &InmemLogStorage{
clock: clock,
emitted: make([]*Record, 0),
bulks: make([]int, 0),
quota: quota,
}
}
func (l *InmemLogStorage) QuotaUnit() quota.Unit {
return quota.Unimplemented
}
func (l *InmemLogStorage) Emit(_ context.Context, bulk []*Record) error {
if len(bulk) == 0 {
return nil
}
l.mux.Lock()
defer l.mux.Unlock()
l.emitted = append(l.emitted, bulk...)
l.bulks = append(l.bulks, len(bulk))
return nil
}
func (l *InmemLogStorage) QueryUsage(_ context.Context, _ string, start time.Time) (uint64, error) {
l.mux.Lock()
defer l.mux.Unlock()
var count uint64
for _, r := range l.emitted {
if r.ts.After(start) {
count++
}
}
return count, nil
}
func (l *InmemLogStorage) Cleanup(_ context.Context, keep time.Duration) error {
l.mux.Lock()
defer l.mux.Unlock()
clean := make([]*Record, 0)
from := l.clock.Now().Add(-(keep + 1))
for _, r := range l.emitted {
if r.ts.After(from) {
clean = append(clean, r)
}
}
l.emitted = clean
return nil
}
func (l *InmemLogStorage) Bulks() []int {
l.mux.Lock()
defer l.mux.Unlock()
return l.bulks
}
func (l *InmemLogStorage) Len() int {
l.mux.Lock()
defer l.mux.Unlock()
return len(l.emitted)
}
func (l *InmemLogStorage) GetQuota(ctx context.Context, instanceID string, unit quota.Unit) (qu *query.Quota, err error) {
return l.quota, nil
}
func (l *InmemLogStorage) GetQuotaUsage(ctx context.Context, instanceID string, unit quota.Unit, periodStart time.Time) (usage uint64, err error) {
return uint64(l.Len()), nil
}
func (l *InmemLogStorage) GetRemainingQuotaUsage(ctx context.Context, instanceID string, unit quota.Unit) (remaining *uint64, err error) {
if !l.quota.Limit {
return nil, nil
}
var r uint64
used := uint64(l.Len())
if used > l.quota.Amount {
return &r, nil
}
r = l.quota.Amount - used
return &r, nil
}
func (l *InmemLogStorage) GetDueQuotaNotifications(ctx context.Context, instanceID string, unit quota.Unit, qu *query.Quota, periodStart time.Time, usedAbs uint64) (dueNotifications []*quota.NotificationDueEvent, err error) {
return nil, nil
}
func (l *InmemLogStorage) ReportQuotaUsage(ctx context.Context, dueNotifications []*quota.NotificationDueEvent) error {
return nil
}

View File

@@ -0,0 +1,25 @@
package mock
import (
"time"
"github.com/benbjohnson/clock"
"github.com/zitadel/zitadel/internal/logstore"
)
var _ logstore.LogRecord[*Record] = (*Record)(nil)
func NewRecord(clock clock.Clock) *Record {
return &Record{ts: clock.Now()}
}
type Record struct {
ts time.Time
redacted bool
}
func (r Record) Normalize() *Record {
r.redacted = true
return &r
}

View File

@@ -0,0 +1,135 @@
package record
import (
"net/http"
"strings"
"time"
"google.golang.org/grpc/codes"
zitadel_http "github.com/zitadel/zitadel/internal/api/http"
)
type AccessLog struct {
LogDate time.Time `json:"logDate"`
Protocol AccessProtocol `json:"protocol"`
RequestURL string `json:"requestUrl"`
ResponseStatus uint32 `json:"responseStatus"`
// RequestHeaders and ResponseHeaders are plain maps so varying implementations
// between HTTP and gRPC don't interfere with each other
RequestHeaders map[string][]string `json:"requestHeaders"`
ResponseHeaders map[string][]string `json:"responseHeaders"`
InstanceID string `json:"instanceId"`
ProjectID string `json:"projectId"`
RequestedDomain string `json:"requestedDomain"`
RequestedHost string `json:"requestedHost"`
// NotCountable can be used by the logging service to explicitly stating,
// that the request must not increase the amount of countable (authenticated) requests
NotCountable bool `json:"-"`
normalized bool `json:"-"`
}
type AccessProtocol uint8
const (
GRPC AccessProtocol = iota
HTTP
redacted = "[REDACTED]"
)
var (
unaccountableEndpoints = []string{
"/zitadel.system.v1.SystemService/",
"/zitadel.admin.v1.AdminService/Healthz",
"/zitadel.management.v1.ManagementService/Healthz",
"/zitadel.management.v1.ManagementService/GetOIDCInformation",
"/zitadel.auth.v1.AuthService/Healthz",
}
)
func (a AccessLog) IsAuthenticated() bool {
if a.NotCountable {
return false
}
if !a.normalized {
panic("access log not normalized, Normalize() must be called before IsAuthenticated()")
}
_, hasHTTPAuthHeader := a.RequestHeaders[strings.ToLower(zitadel_http.Authorization)]
// ignore requests, which were unauthorized or do not require an authorization (even if one was sent)
// also ignore if the limit was already reached or if the server returned an internal error
// not that endpoints paths are only checked with the gRPC representation as HTTP (gateway) will not log them
return hasHTTPAuthHeader &&
(a.Protocol == HTTP &&
a.ResponseStatus != http.StatusInternalServerError &&
a.ResponseStatus != http.StatusTooManyRequests &&
a.ResponseStatus != http.StatusUnauthorized) ||
(a.Protocol == GRPC &&
a.ResponseStatus != uint32(codes.Internal) &&
a.ResponseStatus != uint32(codes.ResourceExhausted) &&
a.ResponseStatus != uint32(codes.Unauthenticated) &&
!a.isUnaccountableEndpoint())
}
func (a AccessLog) isUnaccountableEndpoint() bool {
for _, endpoint := range unaccountableEndpoints {
if strings.HasPrefix(a.RequestURL, endpoint) {
return true
}
}
return false
}
func (a AccessLog) Normalize() *AccessLog {
a.RequestedDomain = cutString(a.RequestedDomain, 200)
a.RequestURL = cutString(a.RequestURL, 200)
a.RequestHeaders = normalizeHeaders(a.RequestHeaders, strings.ToLower(zitadel_http.Authorization), "grpcgateway-authorization", "cookie", "grpcgateway-cookie")
a.ResponseHeaders = normalizeHeaders(a.ResponseHeaders, "set-cookie")
a.normalized = true
return &a
}
// normalizeHeaders lowers all header keys and redacts secrets
func normalizeHeaders(header map[string][]string, redactKeysLower ...string) map[string][]string {
return pruneKeys(redactKeys(lowerKeys(header), redactKeysLower...))
}
func lowerKeys(header map[string][]string) map[string][]string {
lower := make(map[string][]string, len(header))
for k, v := range header {
lower[strings.ToLower(k)] = v
}
return lower
}
func redactKeys(header map[string][]string, redactKeysLower ...string) map[string][]string {
redactedKeys := make(map[string][]string, len(header))
for k, v := range header {
redactedKeys[k] = v
}
for _, redactKey := range redactKeysLower {
if _, ok := redactedKeys[redactKey]; ok {
redactedKeys[redactKey] = []string{redacted}
}
}
return redactedKeys
}
const maxValuesPerKey = 10
func pruneKeys(header map[string][]string) map[string][]string {
prunedKeys := make(map[string][]string, len(header))
for key, value := range header {
valueItems := make([]string, 0, maxValuesPerKey)
for i, valueItem := range value {
// Max 10 header values per key
if i > maxValuesPerKey {
break
}
// Max 200 value length
valueItems = append(valueItems, cutString(valueItem, 200))
}
prunedKeys[key] = valueItems
}
return prunedKeys
}

View File

@@ -0,0 +1,78 @@
package record
import (
"reflect"
"testing"
)
func TestRecord_Normalize(t *testing.T) {
tests := []struct {
name string
record AccessLog
want *AccessLog
}{{
name: "headers with certain keys should be redacted",
record: AccessLog{
RequestHeaders: map[string][]string{
"authorization": {"AValue"},
"grpcgateway-authorization": {"AValue"},
"cookie": {"AValue"},
"grpcgateway-cookie": {"AValue"},
}, ResponseHeaders: map[string][]string{
"set-cookie": {"AValue"},
},
},
want: &AccessLog{
RequestHeaders: map[string][]string{
"authorization": {"[REDACTED]"},
"grpcgateway-authorization": {"[REDACTED]"},
"cookie": {"[REDACTED]"},
"grpcgateway-cookie": {"[REDACTED]"},
}, ResponseHeaders: map[string][]string{
"set-cookie": {"[REDACTED]"},
},
},
}, {
name: "header keys should be lower cased",
record: AccessLog{
RequestHeaders: map[string][]string{"AKey": {"AValue"}},
ResponseHeaders: map[string][]string{"AKey": {"AValue"}}},
want: &AccessLog{
RequestHeaders: map[string][]string{"akey": {"AValue"}},
ResponseHeaders: map[string][]string{"akey": {"AValue"}}},
}, {
name: "an already prune record should stay unchanged",
record: AccessLog{
RequestURL: "https://my.zitadel.cloud/",
RequestHeaders: map[string][]string{
"authorization": {"[REDACTED]"},
},
ResponseHeaders: map[string][]string{},
},
want: &AccessLog{
RequestURL: "https://my.zitadel.cloud/",
RequestHeaders: map[string][]string{
"authorization": {"[REDACTED]"},
},
ResponseHeaders: map[string][]string{},
},
}, {
name: "empty record should stay empty",
record: AccessLog{
RequestHeaders: map[string][]string{},
ResponseHeaders: map[string][]string{},
},
want: &AccessLog{
RequestHeaders: map[string][]string{},
ResponseHeaders: map[string][]string{},
},
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.want.normalized = true
if got := tt.record.Normalize(); !reflect.DeepEqual(got, tt.want) {
t.Errorf("Normalize() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,22 @@
package record
import (
"time"
"github.com/sirupsen/logrus"
)
type ExecutionLog struct {
LogDate time.Time `json:"logDate"`
Took time.Duration `json:"took"`
Message string `json:"message"`
LogLevel logrus.Level `json:"logLevel"`
InstanceID string `json:"instanceId"`
ActionID string `json:"actionId,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
func (e ExecutionLog) Normalize() *ExecutionLog {
e.Message = cutString(e.Message, 2000)
return &e
}

View File

@@ -0,0 +1,8 @@
package record
func cutString(str string, pos int) string {
if len(str) <= pos {
return str
}
return str[:pos-1]
}

View File

@@ -0,0 +1,71 @@
package logstore
import (
"context"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/repository/quota"
)
type UsageStorer[T LogRecord[T]] interface {
LogEmitter[T]
QuotaUnit() quota.Unit
}
type Service[T LogRecord[T]] struct {
queries Queries
usageStorer UsageStorer[T]
enabledSinks []*emitter[T]
sinkEnabled bool
reportingEnabled bool
}
type Queries interface {
GetRemainingQuotaUsage(ctx context.Context, instanceID string, unit quota.Unit) (remaining *uint64, err error)
}
func New[T LogRecord[T]](queries Queries, usageQuerierSink *emitter[T], additionalSink ...*emitter[T]) *Service[T] {
var usageStorer UsageStorer[T]
if usageQuerierSink != nil {
usageStorer = usageQuerierSink.emitter.(UsageStorer[T])
}
svc := &Service[T]{
queries: queries,
reportingEnabled: usageQuerierSink != nil && usageQuerierSink.enabled,
usageStorer: usageStorer,
}
for _, s := range append([]*emitter[T]{usageQuerierSink}, additionalSink...) {
if s != nil && s.enabled {
svc.enabledSinks = append(svc.enabledSinks, s)
}
}
svc.sinkEnabled = len(svc.enabledSinks) > 0
return svc
}
func (s *Service[T]) Enabled() bool {
return s.sinkEnabled
}
func (s *Service[T]) Handle(ctx context.Context, record T) {
for _, sink := range s.enabledSinks {
logging.OnError(sink.Emit(ctx, record.Normalize())).WithField("record", record).Warn("failed to emit log record")
}
}
func (s *Service[T]) Limit(ctx context.Context, instanceID string) *uint64 {
var err error
defer func() {
logging.OnError(err).Warn("failed to check if usage should be limited")
}()
if !s.reportingEnabled || instanceID == "" {
return nil
}
remaining, err := s.queries.GetRemainingQuotaUsage(ctx, instanceID, s.usageStorer.QuotaUnit())
if err != nil {
// TODO: shouldn't we just limit then or return the error and decide there?
return nil
}
return remaining
}

View File

@@ -0,0 +1,286 @@
// The library github.com/benbjohnson/clock fails when race is enabled
// https://github.com/benbjohnson/clock/issues/44
//go:build !race
package logstore_test
import (
"context"
"reflect"
"runtime"
"testing"
"time"
"github.com/benbjohnson/clock"
"github.com/zitadel/zitadel/internal/logstore"
emittermock "github.com/zitadel/zitadel/internal/logstore/mock"
"github.com/zitadel/zitadel/internal/query"
)
const (
tick = time.Second
ticks = 60
)
type args struct {
mainSink *logstore.EmitterConfig
secondarySink *logstore.EmitterConfig
config *query.Quota
}
type want struct {
enabled bool
remaining *uint64
mainSink wantSink
secondarySink wantSink
}
type wantSink struct {
bulks []int
len int
}
func TestService(t *testing.T) {
// tests should run on a single thread
// important for deterministic results
beforeProcs := runtime.GOMAXPROCS(1)
defer runtime.GOMAXPROCS(beforeProcs)
tests := []struct {
name string
args args
want want
}{{
name: "max and min debouncing works",
args: args{
mainSink: emitterConfig(withDebouncerConfig(&logstore.DebouncerConfig{
MinFrequency: 1 * time.Minute,
MaxBulkSize: 60,
})),
secondarySink: emitterConfig(),
config: quotaConfig(),
},
want: want{
enabled: true,
remaining: nil,
mainSink: wantSink{
bulks: repeat(60, 1),
len: 60,
},
secondarySink: wantSink{
bulks: repeat(1, 60),
len: 60,
},
},
}, {
name: "mixed debouncing works",
args: args{
mainSink: emitterConfig(withDebouncerConfig(&logstore.DebouncerConfig{
MinFrequency: 0,
MaxBulkSize: 6,
})),
secondarySink: emitterConfig(withDebouncerConfig(&logstore.DebouncerConfig{
MinFrequency: 10 * time.Second,
MaxBulkSize: 0,
})),
config: quotaConfig(),
},
want: want{
enabled: true,
remaining: nil,
mainSink: wantSink{
bulks: repeat(6, 10),
len: 60,
},
secondarySink: wantSink{
bulks: repeat(10, 6),
len: 60,
},
},
}, {
name: "when disabling main sink, secondary sink still works",
args: args{
mainSink: emitterConfig(withDisabled()),
secondarySink: emitterConfig(),
config: quotaConfig(),
},
want: want{
enabled: true,
remaining: nil,
mainSink: wantSink{
bulks: repeat(99, 0),
len: 0,
},
secondarySink: wantSink{
bulks: repeat(1, 60),
len: 60,
},
},
}, {
name: "when all sink are disabled, the service is disabled",
args: args{
mainSink: emitterConfig(withDisabled()),
secondarySink: emitterConfig(withDisabled()),
config: quotaConfig(),
},
want: want{
enabled: false,
remaining: nil,
mainSink: wantSink{
bulks: repeat(99, 0),
len: 0,
},
secondarySink: wantSink{
bulks: repeat(99, 0),
len: 0,
},
},
}, {
name: "when quota has a limit of 90, 30 are remaining",
args: args{
mainSink: emitterConfig(),
secondarySink: emitterConfig(),
config: quotaConfig(withLimiting()),
},
want: want{
enabled: true,
remaining: uint64Ptr(30),
mainSink: wantSink{
bulks: repeat(1, 60),
len: 60,
},
secondarySink: wantSink{
bulks: repeat(1, 60),
len: 60,
},
},
}, {
name: "when quota has a limit of 30, 0 are remaining",
args: args{
mainSink: emitterConfig(),
secondarySink: emitterConfig(),
config: quotaConfig(withLimiting(), withAmountAndInterval(30)),
},
want: want{
enabled: true,
remaining: uint64Ptr(0),
mainSink: wantSink{
bulks: repeat(1, 60),
len: 60,
},
secondarySink: wantSink{
bulks: repeat(1, 60),
len: 60,
},
},
}, {
name: "when quota has amount of 30 but is not limited, remaining is nil",
args: args{
mainSink: emitterConfig(),
secondarySink: emitterConfig(),
config: quotaConfig(withAmountAndInterval(30)),
},
want: want{
enabled: true,
remaining: nil,
mainSink: wantSink{
bulks: repeat(1, 60),
len: 60,
},
secondarySink: wantSink{
bulks: repeat(1, 60),
len: 60,
},
},
}}
for _, tt := range tests {
runTest(t, tt.name, tt.args, tt.want)
}
}
func runTest(t *testing.T, name string, args args, want want) bool {
return t.Run("Given over a minute, each second a log record is emitted", func(tt *testing.T) {
tt.Run(name, func(t *testing.T) {
ctx, clock, mainStorage, secondaryStorage, svc := given(t, args, want)
remaining := when(svc, ctx, clock)
then(t, mainStorage, secondaryStorage, remaining, want)
})
})
}
func given(t *testing.T, args args, want want) (context.Context, *clock.Mock, *emittermock.InmemLogStorage, *emittermock.InmemLogStorage, *logstore.Service[*emittermock.Record]) {
ctx := context.Background()
clock := clock.NewMock()
clock.Set(args.config.From)
mainStorage := emittermock.NewInMemoryStorage(clock, args.config)
mainEmitter, err := logstore.NewEmitter[*emittermock.Record](ctx, clock, args.mainSink, mainStorage)
if err != nil {
t.Errorf("expected no error but got %v", err)
}
secondaryStorage := emittermock.NewInMemoryStorage(clock, args.config)
secondaryEmitter, err := logstore.NewEmitter[*emittermock.Record](ctx, clock, args.secondarySink, secondaryStorage)
if err != nil {
t.Errorf("expected no error but got %v", err)
}
svc := logstore.New[*emittermock.Record](
mainStorage,
mainEmitter,
secondaryEmitter)
if svc.Enabled() != want.enabled {
t.Errorf("wantet service enabled to be %t but is %t", want.enabled, svc.Enabled())
}
return ctx, clock, mainStorage, secondaryStorage, svc
}
func when(svc *logstore.Service[*emittermock.Record], ctx context.Context, clock *clock.Mock) *uint64 {
var remaining *uint64
for i := 0; i < ticks; i++ {
svc.Handle(ctx, emittermock.NewRecord(clock))
runtime.Gosched()
remaining = svc.Limit(ctx, "non-empty-instance-id")
clock.Add(tick)
}
time.Sleep(time.Millisecond)
runtime.Gosched()
return remaining
}
func then(t *testing.T, mainStorage, secondaryStorage *emittermock.InmemLogStorage, remaining *uint64, want want) {
mainBulks := mainStorage.Bulks()
if !reflect.DeepEqual(want.mainSink.bulks, mainBulks) {
t.Errorf("wanted main storage to have bulks %v, but got %v", want.mainSink.bulks, mainBulks)
}
mainLen := mainStorage.Len()
if !reflect.DeepEqual(want.mainSink.len, mainLen) {
t.Errorf("wanted main storage to have len %d, but got %d", want.mainSink.len, mainLen)
}
secondaryBulks := secondaryStorage.Bulks()
if !reflect.DeepEqual(want.secondarySink.bulks, secondaryBulks) {
t.Errorf("wanted secondary storage to have bulks %v, but got %v", want.secondarySink.bulks, secondaryBulks)
}
secondaryLen := secondaryStorage.Len()
if !reflect.DeepEqual(want.secondarySink.len, secondaryLen) {
t.Errorf("wanted secondary storage to have len %d, but got %d", want.secondarySink.len, secondaryLen)
}
if remaining == nil && want.remaining == nil {
return
}
if remaining == nil && want.remaining != nil ||
remaining != nil && want.remaining == nil {
t.Errorf("wantet remaining nil %t but got %t", want.remaining == nil, remaining == nil)
return
}
if *remaining != *want.remaining {
t.Errorf("wantet remaining %d but got %d", *want.remaining, *remaining)
return
}
}