Skip to content

Commit 60069c9

Browse files
feat(pre-aggregation): Extract enrich in a dedicated processor (#561)
1 parent bcceb46 commit 60069c9

6 files changed

Lines changed: 347 additions & 106 deletions

File tree

events-processor/models/event.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ type SourceMetadata struct {
2727
}
2828

2929
type EnrichedEvent struct {
30-
IntialEvent *Event `json:"-"`
30+
IntialEvent *Event `json:"-"`
31+
BillableMetric *BillableMetric `json:"-"`
32+
Subscription *Subscription `json:"-"`
3133

3234
OrganizationID string `json:"organization_id"`
3335
ExternalSubscriptionID string `json:"external_subscription_id"`
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package event_processors
2+
3+
import (
4+
"github.com/getlago/lago/events-processor/models"
5+
"github.com/getlago/lago/events-processor/utils"
6+
)
7+
8+
func failedResult(r utils.AnyResult, code string, message string) utils.Result[*models.EnrichedEvent] {
9+
result := utils.FailedResult[*models.EnrichedEvent](r.Error()).AddErrorDetails(code, message)
10+
result.Retryable = r.IsRetryable()
11+
result.Capture = r.IsCapturable()
12+
return result
13+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package event_processors
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
7+
"github.com/getlago/lago-expression/expression-go"
8+
"github.com/getlago/lago/events-processor/models"
9+
"github.com/getlago/lago/events-processor/utils"
10+
)
11+
12+
type EventEnrichmentService struct {
13+
apiStore *models.ApiStore
14+
}
15+
16+
func NewEventEnrichmentService(apiStore *models.ApiStore) *EventEnrichmentService {
17+
return &EventEnrichmentService{
18+
apiStore: apiStore,
19+
}
20+
}
21+
22+
func (s *EventEnrichmentService) EnrichEvent(event *models.Event) utils.Result[*models.EnrichedEvent] {
23+
enrichedEventResult := event.ToEnrichedEvent()
24+
if enrichedEventResult.Failure() {
25+
return failedResult(enrichedEventResult, "build_enriched_event", "Error while converting event to enriched event")
26+
}
27+
enrichedEvent := enrichedEventResult.Value()
28+
29+
bmResult := s.apiStore.FetchBillableMetric(event.OrganizationID, event.Code)
30+
if bmResult.Failure() {
31+
return failedResult(bmResult, "fetch_billable_metric", "Error fetching billable metric")
32+
}
33+
34+
bm := bmResult.Value()
35+
if bm != nil {
36+
enrichBmResult := s.enrichWithBillableMetric(enrichedEvent, bm)
37+
if enrichBmResult.Failure() {
38+
return enrichBmResult
39+
}
40+
}
41+
42+
subResult := s.apiStore.FetchSubscription(event.OrganizationID, event.ExternalSubscriptionID, enrichedEvent.Time)
43+
if subResult.Failure() && subResult.IsCapturable() {
44+
// We want to keep processing the event even if the subscription is not found
45+
return failedResult(subResult, "fetch_subscription", "Error fetching subscription")
46+
}
47+
48+
sub := subResult.Value()
49+
if sub != nil {
50+
enrichSubResult := s.enrichWithSubscription(enrichedEvent, sub)
51+
if enrichSubResult.Failure() {
52+
return enrichSubResult
53+
}
54+
}
55+
56+
return utils.SuccessResult(enrichedEvent)
57+
}
58+
59+
func (s *EventEnrichmentService) enrichWithBillableMetric(enrichedEvent *models.EnrichedEvent, bm *models.BillableMetric) utils.Result[*models.EnrichedEvent] {
60+
enrichedEvent.BillableMetric = bm
61+
enrichedEvent.AggregationType = bm.AggregationType.String()
62+
63+
if enrichedEvent.Source != models.HTTP_RUBY {
64+
expressionResult := s.evaluateExpression(enrichedEvent, bm)
65+
if expressionResult.Failure() {
66+
return failedResult(expressionResult, "evaluate_expression", "Error evaluating custom expression")
67+
}
68+
}
69+
70+
var value = fmt.Sprintf("%v", enrichedEvent.Properties[bm.FieldName])
71+
enrichedEvent.Value = &value
72+
73+
return utils.SuccessResult(enrichedEvent)
74+
}
75+
76+
func (s *EventEnrichmentService) evaluateExpression(ev *models.EnrichedEvent, bm *models.BillableMetric) utils.Result[bool] {
77+
if bm.Expression == "" {
78+
return utils.SuccessResult(false)
79+
}
80+
81+
eventJson, err := json.Marshal(ev)
82+
if err != nil {
83+
return utils.FailedBoolResult(err).NonRetryable()
84+
}
85+
eventJsonString := string(eventJson[:])
86+
87+
result := expression.Evaluate(bm.Expression, eventJsonString)
88+
if result != nil {
89+
ev.Properties[bm.FieldName] = *result
90+
} else {
91+
return utils.
92+
FailedBoolResult(fmt.Errorf("Failed to evaluate expr: %s with json: %s", bm.Expression, eventJsonString)).
93+
NonRetryable()
94+
}
95+
96+
return utils.SuccessResult(true)
97+
}
98+
99+
func (s *EventEnrichmentService) enrichWithSubscription(enrichedEvent *models.EnrichedEvent, sub *models.Subscription) utils.Result[*models.EnrichedEvent] {
100+
enrichedEvent.Subscription = sub
101+
enrichedEvent.SubscriptionID = sub.ID
102+
enrichedEvent.PlanID = sub.PlanID
103+
104+
return utils.SuccessResult(enrichedEvent)
105+
}
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
package event_processors
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/DATA-DOG/go-sqlmock"
8+
"github.com/getlago/lago/events-processor/models"
9+
"github.com/getlago/lago/events-processor/tests"
10+
"github.com/getlago/lago/events-processor/utils"
11+
"github.com/stretchr/testify/assert"
12+
"gorm.io/gorm"
13+
)
14+
15+
var processor *EventEnrichmentService
16+
17+
func setupTestEnv(t *testing.T) (sqlmock.Sqlmock, func()) {
18+
db, mock, delete := tests.SetupMockStore(t)
19+
apiStore := models.NewApiStore(db)
20+
21+
processor = &EventEnrichmentService{
22+
apiStore: apiStore,
23+
}
24+
25+
return mock, delete
26+
}
27+
28+
func mockBmLookup(sqlmock sqlmock.Sqlmock, bm *models.BillableMetric) {
29+
columns := []string{"id", "organization_id", "code", "aggregation_type", "field_name", "expression", "created_at", "updated_at", "deleted_at"}
30+
31+
rows := sqlmock.NewRows(columns).
32+
AddRow(bm.ID, bm.OrganizationID, bm.Code, bm.AggregationType, bm.FieldName, bm.Expression, bm.CreatedAt, bm.UpdatedAt, bm.DeletedAt)
33+
34+
sqlmock.ExpectQuery("SELECT \\* FROM \"billable_metrics\".*").WillReturnRows(rows)
35+
}
36+
37+
func mockSubscriptionLookup(sqlmock sqlmock.Sqlmock, sub *models.Subscription) {
38+
columns := []string{"id", "external_id", "plan_id", "created_at", "updated_at", "terminated_at"}
39+
40+
rows := sqlmock.NewRows(columns).
41+
AddRow(sub.ID, sub.ExternalID, sub.PlanID, sub.CreatedAt, sub.UpdatedAt, sub.TerminatedAt)
42+
43+
sqlmock.ExpectQuery(".* FROM \"subscriptions\".*").WillReturnRows(rows)
44+
}
45+
46+
func TestEnrichEvent(t *testing.T) {
47+
t.Run("Without Billable Metric", func(t *testing.T) {
48+
sqlmock, delete := setupTestEnv(t)
49+
defer delete()
50+
51+
event := models.Event{
52+
OrganizationID: "1a901a90-1a90-1a90-1a90-1a901a901a90",
53+
ExternalSubscriptionID: "sub_id",
54+
Code: "api_calls",
55+
Timestamp: 1741007009,
56+
}
57+
58+
sqlmock.ExpectQuery(".*").WillReturnError(gorm.ErrRecordNotFound)
59+
60+
result := processor.EnrichEvent(&event)
61+
assert.False(t, result.Success())
62+
assert.Equal(t, "record not found", result.ErrorMsg())
63+
assert.Equal(t, "fetch_billable_metric", result.ErrorCode())
64+
assert.Equal(t, "Error fetching billable metric", result.ErrorMessage())
65+
})
66+
67+
t.Run("When event source is post processed on API and the result is successful", func(t *testing.T) {
68+
sqlmock, delete := setupTestEnv(t)
69+
defer delete()
70+
71+
properties := map[string]any{
72+
"api_requests": "12.0",
73+
}
74+
75+
event := models.Event{
76+
OrganizationID: "1a901a90-1a90-1a90-1a90-1a901a901a90",
77+
ExternalSubscriptionID: "sub_id",
78+
Code: "api_calls",
79+
Timestamp: 1741007009,
80+
Source: models.HTTP_RUBY,
81+
Properties: properties,
82+
SourceMetadata: &models.SourceMetadata{
83+
ApiPostProcess: true,
84+
},
85+
}
86+
87+
bm := models.BillableMetric{
88+
ID: "bm123",
89+
OrganizationID: event.OrganizationID,
90+
Code: event.Code,
91+
AggregationType: models.AggregationTypeSum,
92+
FieldName: "api_requests",
93+
Expression: "",
94+
CreatedAt: time.Now(),
95+
UpdatedAt: time.Now(),
96+
}
97+
mockBmLookup(sqlmock, &bm)
98+
99+
sub := models.Subscription{ID: "sub123", PlanID: "plan123"}
100+
mockSubscriptionLookup(sqlmock, &sub)
101+
102+
result := processor.EnrichEvent(&event)
103+
104+
assert.True(t, result.Success())
105+
assert.Equal(t, "12.0", *result.Value().Value)
106+
assert.Equal(t, "sum", result.Value().AggregationType)
107+
assert.Equal(t, "sub123", result.Value().SubscriptionID)
108+
assert.Equal(t, "plan123", result.Value().PlanID)
109+
})
110+
111+
t.Run("When timestamp is invalid", func(t *testing.T) {
112+
sqlmock, delete := setupTestEnv(t)
113+
defer delete()
114+
115+
event := models.Event{
116+
OrganizationID: "1a901a90-1a90-1a90-1a90-1a901a901a90",
117+
ExternalSubscriptionID: "sub_id",
118+
Code: "api_calls",
119+
Timestamp: "2025-03-06T12:00:00Z",
120+
Source: "SQS",
121+
}
122+
123+
bm := models.BillableMetric{
124+
ID: "bm123",
125+
OrganizationID: event.OrganizationID,
126+
Code: event.Code,
127+
AggregationType: models.AggregationTypeWeightedSum,
128+
FieldName: "api_requests",
129+
Expression: "",
130+
CreatedAt: time.Now(),
131+
UpdatedAt: time.Now(),
132+
}
133+
mockBmLookup(sqlmock, &bm)
134+
135+
result := processor.EnrichEvent(&event)
136+
assert.False(t, result.Success())
137+
assert.Equal(t, "strconv.ParseFloat: parsing \"2025-03-06T12:00:00Z\": invalid syntax", result.ErrorMsg())
138+
assert.Equal(t, "build_enriched_event", result.ErrorCode())
139+
assert.Equal(t, "Error while converting event to enriched event", result.ErrorMessage())
140+
})
141+
142+
t.Run("When expression failed to evaluate", func(t *testing.T) {
143+
sqlmock, delete := setupTestEnv(t)
144+
defer delete()
145+
146+
event := models.Event{
147+
OrganizationID: "1a901a90-1a90-1a90-1a90-1a901a901a90",
148+
ExternalSubscriptionID: "sub_id",
149+
Code: "api_calls",
150+
Timestamp: "1741007009.123",
151+
Source: "SQS",
152+
}
153+
154+
bm := models.BillableMetric{
155+
ID: "bm123",
156+
OrganizationID: event.OrganizationID,
157+
Code: event.Code,
158+
AggregationType: models.AggregationTypeWeightedSum,
159+
FieldName: "api_requests",
160+
Expression: "round(event.properties.value)",
161+
CreatedAt: time.Now(),
162+
UpdatedAt: time.Now(),
163+
}
164+
mockBmLookup(sqlmock, &bm)
165+
166+
result := processor.EnrichEvent(&event)
167+
assert.False(t, result.Success())
168+
assert.Contains(t, result.ErrorMsg(), "Failed to evaluate expr: round(event.properties.value)")
169+
assert.Equal(t, "evaluate_expression", result.ErrorCode())
170+
assert.Equal(t, "Error evaluating custom expression", result.ErrorMessage())
171+
})
172+
}
173+
174+
func TestEvaluateExpression(t *testing.T) {
175+
_, delete := setupTestEnv(t)
176+
defer delete()
177+
178+
bm := models.BillableMetric{}
179+
event := models.EnrichedEvent{Timestamp: 1741007009.0, Code: "foo"}
180+
var result utils.Result[bool]
181+
182+
t.Run("Without expression", func(t *testing.T) {
183+
result = processor.evaluateExpression(&event, &bm)
184+
assert.True(t, result.Success(), "It should succeed when Billable metric does not have a custom expression")
185+
})
186+
187+
t.Run("With an expression but witout required fields", func(t *testing.T) {
188+
bm.Expression = "round(event.properties.value * event.properties.units)"
189+
bm.FieldName = "total_value"
190+
result = processor.evaluateExpression(&event, &bm)
191+
assert.False(t, result.Success())
192+
assert.Contains(
193+
t,
194+
result.ErrorMsg(),
195+
"Failed to evaluate expr:",
196+
"It should fail when the event does not hold the required fields",
197+
)
198+
})
199+
200+
t.Run("With an expression and with required fields", func(t *testing.T) {
201+
properties := map[string]any{
202+
"value": "12.0",
203+
"units": 3,
204+
}
205+
event.Properties = properties
206+
result = processor.evaluateExpression(&event, &bm)
207+
assert.True(t, result.Success())
208+
assert.Equal(t, "36", event.Properties["total_value"])
209+
})
210+
211+
t.Run("With a float timestamp", func(t *testing.T) {
212+
event.Timestamp = 1741007009.123
213+
214+
result = processor.evaluateExpression(&event, &bm)
215+
assert.True(t, result.Success())
216+
assert.Equal(t, "36", event.Properties["total_value"])
217+
})
218+
}

0 commit comments

Comments
 (0)