Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion pkg/analytics/posthog/posthog.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,11 +147,27 @@ func (p *PosthogAnalytics) Count(ctx context.Context, resource analytics.Resourc
merged[k] = v
}
}

// Extract count from last properties arg if multiple are provided.
// Allows passing aggregated counts instead of always using 1.
Comment on lines +151 to +152
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tbh I still think we should be passing in a structured type to determine/override this value, where necessary instead of relying on a positional arg in the varadic params.

Like, it's more explicit that way i.e

a.Count(ctx, analytics.Log, analytics.Get, analytics.Properties{}, analytics.Increment(5))

versus:

a.Count(ctx, analytics.Log, analytics.Get, analytics.Properties{}, analytics.Properties{"": 5})

where the latter could be error prone as well since it means we'd need to document this positional behaviour.

This typed-approach also means we can extend supported operations i.e

a.Count(ctx, analytics.Log, analytics.Get, analytics.Properties{}, analytics.Decrement(5))
a.Count(ctx, analytics.Log, analytics.Get, analytics.Properties{}, analytics.Set(0)) // or maybe .Reset() ?

Then if we have other metric types (i.e a gauge or whatever) we can extend this approach to suit.

Copy link
Copy Markdown
Author

@wolf-06 wolf-06 Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair enough,
will move with original approach of using structured property, which ig will also solve to other concerns automatically.

var n int64
if len(props) > 1 && props[len(props)-1] != nil {
for _, v := range props[len(props)-1] {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unsure this is desirable due to the typing of analytics.Properties.

Like, analytics.Properties is a map[string]any which means (in the case of your changes) the last arg would still have to be correctly typed as such. So if I wanted to increment the count by 5 I'd need to pass a contrived map type where the keys will be ignored i.e

a.Count(ctx, analytics.Log, analytics.Get, analytics.Properties{},  analytics.Properties{"foo": 5}))

Also, if we pass in two (or more) properties, the caller has no way of knowing the final arg will have all of its values used for incrementing. This ambigious behaviour could lead to unintentionally passing props in that we expect to be included as tags/labels but get used to increment metrics.

if value, ok := v.(int64); ok {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This conversion means we're only ever supporting an int64 being explicitly passed in, meaning other numeric types being used would get skipped over.

i.e

a.Count(ctx, analytics.Log, analytics.Get, analytics.Properties{},  analytics.Properties{"foo": uint64(5)}))

Would see 1 being added since a uint64 is not an int64 -- hence it's skipped as type-checking fails.

n += value
}
}
}

if n == 0 {
n = 1
}

if source := analytics.SourceFromContext(ctx); source != "" {
merged["source"] = string(source)
}

p.aggregator.Count(resource, action, tid, tokenID, 1, merged)
p.aggregator.Count(resource, action, tid, tokenID, n, merged)
}

func (p *PosthogAnalytics) flushCount(resource analytics.Resource, action analytics.Action, tenantID uuid.UUID, tokenID *uuid.UUID, count int64, properties analytics.Properties) {
Expand Down
223 changes: 223 additions & 0 deletions pkg/analytics/posthog/posthog_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
package posthog

import (
"context"
"sync"
"testing"

"github.com/google/uuid"
"github.com/rs/zerolog"

"github.com/hatchet-dev/hatchet/pkg/analytics"
)

var testLogger = zerolog.Nop()

type countCall struct {
Resource analytics.Resource
Action analytics.Action
TenantID uuid.UUID
TokenID *uuid.UUID
Count int64
Properties analytics.Properties
}

type countRecorder struct {
mu sync.Mutex
calls []countCall
}

func (r *countRecorder) record(resource analytics.Resource, action analytics.Action, tenantID uuid.UUID, tokenID *uuid.UUID, count int64, properties analytics.Properties) {
r.mu.Lock()
defer r.mu.Unlock()
r.calls = append(r.calls, countCall{
Resource: resource,
Action: action,
TenantID: tenantID,
TokenID: tokenID,
Count: count,
Properties: properties,
})
}

func (r *countRecorder) getCalls() []countCall {
r.mu.Lock()
defer r.mu.Unlock()
cp := make([]countCall, len(r.calls))
copy(cp, r.calls)
return cp
}

func TestCount_DefaultToOne(t *testing.T) {
rec := &countRecorder{}
agg := analytics.NewAggregator(&testLogger, true, 50, 0, rec.record)
agg.Start()
p := &PosthogAnalytics{
aggregator: agg,
}

ctx := context.Background()
p.Count(ctx, analytics.Event, analytics.Create)

agg.Shutdown()

calls := rec.getCalls()
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(calls))
}
if calls[0].Count != 1 {
t.Errorf("expected count 1, got %d", calls[0].Count)
}
}

func TestCount_SinglePropertyFallbackToOne(t *testing.T) {
rec := &countRecorder{}
agg := analytics.NewAggregator(&testLogger, true, 50, 0, rec.record)
agg.Start()
p := &PosthogAnalytics{
aggregator: agg,
}

ctx := context.Background()
p.Count(ctx, analytics.Event, analytics.Create, analytics.Props(
"has_priority", true,
))

agg.Shutdown()

calls := rec.getCalls()
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(calls))
}
if calls[0].Count != 1 {
t.Errorf("expected count 1, got %d", calls[0].Count)
}
}

func TestCount_ExplicitCountFromLastProperties(t *testing.T) {
rec := &countRecorder{}
agg := analytics.NewAggregator(&testLogger, true, 50, 0, rec.record)
agg.Start()
p := &PosthogAnalytics{
aggregator: agg,
}

ctx := context.Background()
p.Count(ctx, analytics.Event, analytics.Create,
analytics.Props("has_priority", true),
analytics.Props("count", int64(5)),
)

agg.Shutdown()

calls := rec.getCalls()
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(calls))
}
if calls[0].Count != 5 {
t.Errorf("expected count 5, got %d", calls[0].Count)
}
}

func TestCount_SumMultipleInt64Values(t *testing.T) {
rec := &countRecorder{}
agg := analytics.NewAggregator(&testLogger, true, 50, 0, rec.record)
agg.Start()
p := &PosthogAnalytics{
aggregator: agg,
}

ctx := context.Background()
p.Count(ctx, analytics.Event, analytics.Create,
analytics.Props("has_priority", true),
analytics.Props("count", int64(2), "other_count", int64(3)),
)

agg.Shutdown()

calls := rec.getCalls()
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(calls))
}
if calls[0].Count != 5 {
t.Errorf("expected count 5 (sum of 2+3), got %d", calls[0].Count)
}
}

func TestCount_IgnoresNonInt64Values(t *testing.T) {
rec := &countRecorder{}
agg := analytics.NewAggregator(&testLogger, true, 50, 0, rec.record)
agg.Start()
p := &PosthogAnalytics{
aggregator: agg,
}

ctx := context.Background()
p.Count(ctx, analytics.Event, analytics.Create,
analytics.Props("has_priority", true),
analytics.Props("count", int64(2), "name", "test", "active", true),
)

agg.Shutdown()

calls := rec.getCalls()
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(calls))
}
if calls[0].Count != 2 {
t.Errorf("expected count 2, got %d", calls[0].Count)
}
}

func TestCount_NilLastPropertiesFallbackToOne(t *testing.T) {
rec := &countRecorder{}
agg := analytics.NewAggregator(&testLogger, true, 50, 0, rec.record)
agg.Start()
p := &PosthogAnalytics{
aggregator: agg,
}

ctx := context.Background()
p.Count(ctx, analytics.Event, analytics.Create,
analytics.Props("has_priority", true),
nil,
)

agg.Shutdown()

calls := rec.getCalls()
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(calls))
}
if calls[0].Count != 1 {
t.Errorf("expected count 1, got %d", calls[0].Count)
}
}

func TestCount_FirstPropertyPassedToAggregator(t *testing.T) {
rec := &countRecorder{}
agg := analytics.NewAggregator(&testLogger, true, 50, 0, rec.record)
agg.Start()
p := &PosthogAnalytics{
aggregator: agg,
}

ctx := context.Background()
p.Count(ctx, analytics.Event, analytics.Create,
analytics.Props("has_priority", true, "source", "api"),
analytics.Props("count", int64(10)),
)

agg.Shutdown()

calls := rec.getCalls()
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(calls))
}
if calls[0].Properties["has_priority"] != true {
t.Error("expected has_priority=true in properties")
}
if calls[0].Properties["source"] != "api" {
t.Error("expected source=api in properties")
}
}