Repository: smartcontractkit/chainlink
Pull request #16994: Send Metering Report to Billing + Beholder
Author: login: EasterTheBunny
Labels: name: runs-on
description: opt-in to self-hosted runners for certain jobs
Created: 2025-03-27T22:16:15Z | Merged: 2025-04-17T16:49:40Z

<!--- Does this work have a corresponding ticket?  Please link it here as well as one of:
    - the PR title
    - branch name
    - commit message
[LINK-777](https://smartcontract-it.atlassian.net/browse/LINK-777)
--> 
[CRE-294](https://smartcontract-it.atlassian.net/browse/CRE-294)

### Requires
<!--- Does this work depend on other open PRs? Please list them.
- https://github.com/smartcontractkit/chainlink-common/pull/7777777
-->
- https://github.com/smartcontractkit/chainlink-common/pull/1088

### Supports
<!--- Does this work support other open PRs?  Please list them.
- https://github.com/smartcontractkit/ccip/pull/7777777
-->


[CRE-294]: https://smartcontract-it.atlassian.net/browse/CRE-294?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ


Diff:
diff --git a/core/services/workflows/engine.go b/core/services/workflows/engine.go
index 6fb4f3a6960..fc938905e5d 100644
--- a/core/services/workflows/engine.go
+++ b/core/services/workflows/engine.go
@@ -15,15 +15,19 @@ import (
 
 	"github.com/smartcontractkit/chainlink-common/pkg/aggregation"
 	"github.com/smartcontractkit/chainlink-common/pkg/beholder"
+	billing "github.com/smartcontractkit/chainlink-common/pkg/billing/pb"
 	"github.com/smartcontractkit/chainlink-common/pkg/capabilities"
 	"github.com/smartcontractkit/chainlink-common/pkg/custmsg"
+	meteringpb "github.com/smartcontractkit/chainlink-common/pkg/metering/pb"
 	"github.com/smartcontractkit/chainlink-common/pkg/metrics"
 	"github.com/smartcontractkit/chainlink-common/pkg/services"
 	"github.com/smartcontractkit/chainlink-common/pkg/types/core"
 	"github.com/smartcontractkit/chainlink-common/pkg/values"
 	"github.com/smartcontractkit/chainlink-common/pkg/workflows"
+	workflowpb "github.com/smartcontractkit/chainlink-common/pkg/workflows/events/pb"
 	"github.com/smartcontractkit/chainlink-common/pkg/workflows/exec"
 	"github.com/smartcontractkit/chainlink-common/pkg/workflows/sdk"
+
 	"github.com/smartcontractkit/chainlink/v2/core/capabilities/transmission"
 	"github.com/smartcontractkit/chainlink/v2/core/logger"
 	"github.com/smartcontractkit/chainlink/v2/core/platform"
@@ -103,6 +107,10 @@ func (sucm *stepUpdateManager) len() int64 {
 
 type SecretsFor func(ctx context.Context, workflowOwner, hexWorkflowName, decodedWorkflowName, workflowID string) (map[string]string, error)
 
+type billingClient interface {
+	SubmitWorkflowReceipt(context.Context, *billing.SubmitWorkflowReceiptRequest) (*billing.SubmitWorkflowReceiptResponse, error)
+}
+
 // Engine handles the lifecycle of a single workflow and its executions.
 type Engine struct {
 	services.StateMachine
@@ -124,6 +132,7 @@ type Engine struct {
 	maxExecutionDuration time.Duration
 	heartbeatCadence     time.Duration
 	stepTimeoutDuration  time.Duration
+	billingClient        billingClient
 
 	// testing lifecycle hook to signal when an execution is finished.
 	onExecutionFinished func(string)
@@ -146,9 +155,6 @@ type Engine struct {
 	ratelimiter    *ratelimiter.RateLimiter
 	workflowLimits *syncerlimiter.Limits
 	meterReports   *MeterReports
-
-	// sendMeteringReport is a test hook to send a metering report
-	sendMeteringReport func(report *MeteringReport, name string, ID string, execID string)
 }
 
 func (e *Engine) Start(_ context.Context) error {
@@ -601,9 +607,6 @@ func (e *Engine) handleStepUpdate(ctx context.Context, stepUpdate store.Workflow
 			e.meterReports.Add(stepUpdate.ExecutionID, NewMeteringReport())
 		}
 
-		report, _ := e.meterReports.Get(stepUpdate.ExecutionID)
-		e.sendMeteringReport(report, e.workflow.name.String(), e.workflow.id, stepUpdate.ExecutionID)
-
 		return e.finishExecution(ctx, cma, state.ExecutionID, status)
 	}
 
@@ -661,6 +664,21 @@ func (e *Engine) finishExecution(ctx context.Context, cma custmsg.MessageEmitter
 		return fmt.Errorf("failed to mark execution as finished: %w", err)
 	}
 
+	report, exists := e.meterReports.Get(executionID)
+	if exists {
+		// send metering report to beholder
+		if err = emitMeteringReport(ctx, cma, report); err != nil {
+			e.metrics.with(platform.KeyWorkflowID, e.workflow.id, platform.KeyWorkflowExecutionID, executionID).incrementWorkflowMissingMeteringReport(ctx)
+			l.Warn(fmt.Sprintf("metering report send to beholder error %s", err))
+		}
+
+		// send metering report to billing if billing client is not nil
+		if err = e.sendMeteringReportToBilling(ctx, report, e.workflow.id, executionID); err != nil {
+			e.metrics.with(platform.KeyWorkflowID, e.workflow.id, platform.KeyWorkflowExecutionID, executionID).incrementWorkflowMissingMeteringReport(ctx)
+			l.Warn(fmt.Sprintf("metering report send to billing error %s", err))
+		}
+	}
+
 	// clean all per execution state trackers
 	e.stepUpdatesChMap.remove(executionID)
 	e.meterReports.Delete(executionID)
@@ -683,13 +701,16 @@ func (e *Engine) finishExecution(ctx context.Context, cma custmsg.MessageEmitter
 		logCustMsg(ctx, cma, fmt.Sprintf("execution duration exceeded 15 minutes: %d (seconds)", executionDuration), l)
 		l.Warnf("execution duration exceeded 15 minutes: %d (seconds)", executionDuration)
 	}
+
 	logCustMsg(ctx, cma, fmt.Sprintf("execution duration: %d (seconds)", executionDuration), l)
 	l.Infof("execution duration: %d (seconds)", executionDuration)
 	err = emitExecutionFinishedEvent(ctx, cma, status, executionID)
 	if err != nil {
 		e.logger.Errorf("failed to emit execution finished event: %+v", err)
 	}
+
 	e.onExecutionFinished(executionID)
+
 	return nil
 }
 
@@ -827,6 +848,7 @@ func (e *Engine) workerForStepRequest(ctx context.Context, msg stepRequest) {
 		if err := rpt.SetStep(MeteringReportStepRef(stepState.Ref), meteringSteps); err != nil {
 			l.Error(fmt.Sprintf("failed to set metering report step for ref %s: %s", stepState.Ref, err))
 		}
+		e.metrics.with(platform.KeyWorkflowID, e.workflow.id, platform.KeyWorkflowExecutionID, msg.state.ExecutionID).incrementWorkflowMissingMeteringReport(ctx)
 	} else {
 		e.metrics.with(platform.KeyWorkflowID, e.workflow.id, platform.KeyWorkflowExecutionID, msg.state.ExecutionID).incrementWorkflowMissingMeteringReport(ctx)
 		// TODO: to be bumped to error if all capabilities must implement metering
@@ -1177,6 +1199,28 @@ func (e *Engine) heartbeat(ctx context.Context) {
 	}
 }
 
+func (e *Engine) sendMeteringReportToBilling(ctx context.Context, report *MeteringReport, workflowID string, executionID string) error {
+	if e.billingClient != nil {
+		req := billing.SubmitWorkflowReceiptRequest{
+			AccountId:           e.workflow.owner,
+			WorkflowId:          workflowID,
+			WorkflowExecutionId: executionID,
+			Metering:            report.Message(),
+		}
+
+		resp, err := e.billingClient.SubmitWorkflowReceipt(ctx, &req)
+		if err != nil {
+			return err
+		}
+
+		if resp == nil || !resp.Success {
+			return errors.New("failed to submit workflow receipt")
+		}
+	}
+
+	return nil
+}
+
 func (e *Engine) Close() error {
 	return e.StopOnce("Engine", func() error {
 		e.logger.Info("shutting down engine")
@@ -1274,6 +1318,7 @@ type Config struct {
 	SecretsFetcher       SecretsFor
 	HeartbeatCadence     time.Duration
 	StepTimeout          time.Duration
+	BillingClient        billingClient
 
 	// RateLimiter limits the workflow execution steps globally and per
 	// second that a workflow owner can make
@@ -1290,7 +1335,6 @@ type Config struct {
 	onExecutionFinished func(weid string)
 	onRateLimit         func(weid string)
 	clock               clockwork.Clock
-	sendMeteringReport  func(report *MeteringReport, name string, ID string, execID string)
 }
 
 const (
@@ -1355,10 +1399,6 @@ func NewEngine(ctx context.Context, cfg Config) (engine *Engine, err error) {
 		cfg.clock = clockwork.NewRealClock()
 	}
 
-	if cfg.sendMeteringReport == nil {
-		cfg.sendMeteringReport = func(*MeteringReport, string, string, string) {}
-	}
-
 	if cfg.RateLimiter == nil {
 		return nil, &workflowError{reason: "ratelimiter must be provided",
 			labels: map[string]string{
@@ -1454,7 +1494,7 @@ func NewEngine(ctx context.Context, cfg Config) (engine *Engine, err error) {
 		ratelimiter:          cfg.RateLimiter,
 		workflowLimits:       cfg.WorkflowLimits,
 		meterReports:         NewMeterReports(),
-		sendMeteringReport:   cfg.sendMeteringReport,
+		billingClient:        cfg.BillingClient,
 	}
 
 	return engine, nil
@@ -1536,6 +1576,43 @@ func buildWorkflowMetadata(kvs map[string]string) *pb.WorkflowMetadata {
 	return m
 }
 
+// buildCommonWorkflowMetadata populates a WorkflowMetadata from kvs (map[string]string).
+func buildCommonWorkflowMetadata(kvs map[string]string) *workflowpb.WorkflowMetadata {
+	m := &workflowpb.WorkflowMetadata{}
+
+	m.WorkflowName = kvs[platform.KeyWorkflowName]
+	m.Version = kvs[platform.KeyWorkflowVersion]
+	m.WorkflowID = kvs[platform.KeyWorkflowID]
+	m.WorkflowExecutionID = kvs[platform.KeyWorkflowExecutionID]
+	m.Owner = kvs[platform.KeyWorkflowOwner]
+
+	if donIDStr, ok := kvs[platform.KeyDonID]; ok {
+		if id, err := strconv.ParseInt(donIDStr, 10, 32); err == nil {
+			m.DonID = int32(id)
+		}
+	}
+
+	m.P2PID = kvs[platform.KeyP2PID]
+
+	if donFStr, ok := kvs[platform.KeyDonF]; ok {
+		if id, err := strconv.ParseInt(donFStr, 10, 32); err == nil {
+			m.DonF = int32(id)
+		}
+	}
+	if donNStr, ok := kvs[platform.KeyDonN]; ok {
+		if id, err := strconv.ParseInt(donNStr, 10, 32); err == nil {
+			m.DonN = int32(id)
+		}
+	}
+	if donQStr, ok := kvs[platform.KeyDonQ]; ok {
+		if id, err := strconv.ParseInt(donQStr, 10, 32); err == nil {
+			m.DonQ = int32(id)
+		}
+	}
+
+	return m
+}
+
 // emitProtoMessage marshals a proto.Message and emits it via beholder.
 func emitProtoMessage(ctx context.Context, msg proto.Message) error {
 	b, err := proto.Marshal(msg)
@@ -1544,33 +1621,41 @@ func emitProtoMessage(ctx context.Context, msg proto.Message) error {
 	}
 
 	// Determine the schema and entity based on the message type
+	// entity must be prefixed with the proto package name
 	var schema, entity string
 	switch msg.(type) {
 	case *pb.WorkflowExecutionStarted:
 		schema = SchemaWorkflowStarted
-		entity = EventWorkflowExecutionStarted
+		entity = fmt.Sprintf("%s.%s", EventsProtoPkg, EventWorkflowExecutionStarted)
 	case *pb.WorkflowExecutionFinished:
 		schema = SchemaWorkflowFinished
-		entity = EventWorkflowExecutionFinished
+		entity = fmt.Sprintf("%s.%s", EventsProtoPkg, EventWorkflowExecutionFinished)
 	case *pb.CapabilityExecutionStarted:
 		schema = SchemaCapabilityStarted
-		entity = EventCapabilityExecutionStarted
+		entity = fmt.Sprintf("%s.%s", EventsProtoPkg, EventCapabilityExecutionStarted)
 	case *pb.CapabilityExecutionFinished:
 		schema = SchemaCapabilityFinished
-		entity = EventCapabilityExecutionFinished
+		entity = fmt.Sprintf("%s.%s", EventsProtoPkg, EventCapabilityExecutionFinished)
+	case *meteringpb.MeteringReport:
+		schema = MeteringReportSchema
+		entity = fmt.Sprintf("%s.%s", MeteringProtoPkg, MeteringReportEntity)
 	default:
 		return fmt.Errorf("unknown message type: %T", msg)
 	}
 
-	// entity must be prefixed with the proto package name
-	entity = fmt.Sprintf("%s.%s", EventsProtoPkg, entity)
-
 	return beholder.GetEmitter().Emit(ctx, b,
 		"beholder_data_schema", schema, // required
 		"beholder_domain", "platform", // required
 		"beholder_entity", entity) // required
 }
 
+func emitMeteringReport(ctx context.Context, cma custmsg.MessageEmitter, report *MeteringReport) error {
+	rpt := report.Message()
+	rpt.Metadata = buildCommonWorkflowMetadata(cma.Labels())
+
+	return emitProtoMessage(ctx, rpt)
+}
+
 func emitExecutionStartedEvent(ctx context.Context, cma custmsg.MessageEmitter, triggerID string, executionID string) error {
 	cma = cma.With(platform.KeyWorkflowExecutionID, executionID)
 	metadata := buildWorkflowMetadata(cma.Labels())
diff --git a/core/services/workflows/engine_test.go b/core/services/workflows/engine_test.go
index 78f970d852c..046e82dece2 100644
--- a/core/services/workflows/engine_test.go
+++ b/core/services/workflows/engine_test.go
@@ -16,7 +16,7 @@ import (
 	"github.com/stretchr/testify/require"
 	"google.golang.org/protobuf/proto"
 
-	"github.com/smartcontractkit/chainlink-common/pkg/beholder"
+	billing "github.com/smartcontractkit/chainlink-common/pkg/billing/pb"
 	"github.com/smartcontractkit/chainlink-common/pkg/capabilities"
 	capabilitiespb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/pb"
 	"github.com/smartcontractkit/chainlink-common/pkg/services/servicetest"
@@ -255,19 +255,6 @@ func newTestEngine(t *testing.T, reg *coreCap.Registry, sdkSpec sdk.WorkflowSpec
 		clock:          clock,
 		RateLimiter:    rl,
 		WorkflowLimits: sl,
-		sendMeteringReport: func(report *MeteringReport, name string, ID string, execID string) {
-			detail := report.Description()
-			bClient := beholder.GetClient()
-			// kvAttrs is what the test client matches on, view pkg/utils/test in common for more detail
-			kvAttrs := []any{"beholder_data_schema", detail.Schema, "beholder_domain", detail.Domain,
-				"beholder_entity", fmt.Sprintf("%s.%s", MeteringProtoPkg, detail.Entity),
-				platform.KeyWorkflowName, name, platform.KeyWorkflowID, ID, platform.KeyWorkflowExecutionID, execID}
-
-			data, mErr := proto.Marshal(report.Message())
-			require.NoError(t, mErr)
-
-			require.NoError(t, bClient.Emitter.Emit(t.Context(), data, kvAttrs...))
-		},
 	}
 	for _, o := range opts {
 		o(&cfg)
@@ -357,6 +344,7 @@ func TestEngineWithHardcodedWorkflow(t *testing.T) {
 	ctx := testutils.Context(t)
 	reg := coreCap.NewRegistry(logger.TestLogger(t))
 	beholderTester := tests.Beholder(t)
+	mBillingClient := new(mockBillingClient)
 
 	trigger, cr := mockTrigger(t)
 
@@ -384,8 +372,15 @@ func TestEngineWithHardcodedWorkflow(t *testing.T) {
 		t,
 		reg,
 		hardcodedWorkflow,
+		func(cfg *Config) {
+			cfg.BillingClient = mBillingClient
+		},
 	)
 
+	mBillingClient.On("SubmitWorkflowReceipt", mock.Anything, mock.MatchedBy(func(req *billing.SubmitWorkflowReceiptRequest) bool {
+		return req != nil && req.WorkflowId != "" && req.WorkflowExecutionId != ""
+	})).Return(&billing.SubmitWorkflowReceiptResponse{Success: true}, nil)
+
 	servicetest.Run(t, eng)
 
 	eid := getExecutionID(t, eng, testHooks)
@@ -405,6 +400,8 @@ func TestEngineWithHardcodedWorkflow(t *testing.T) {
 	assert.Equal(t, 1, beholderTester.Len(t, "beholder_entity", fmt.Sprintf("%s.%s", EventsProtoPkg, EventWorkflowExecutionFinished)))
 	assert.Equal(t, 3, beholderTester.Len(t, "beholder_entity", fmt.Sprintf("%s.%s", EventsProtoPkg, EventCapabilityExecutionStarted)))
 	assert.Equal(t, 3, beholderTester.Len(t, "beholder_entity", fmt.Sprintf("%s.%s", EventsProtoPkg, EventCapabilityExecutionFinished)))
+
+	mBillingClient.AssertExpectations(t)
 }
 
 const (
@@ -2286,3 +2283,18 @@ func TestEngine_ConcurrentExecutions(t *testing.T) {
 	assert.Equal(t, 1, beholderTester.Len(t, platform.KeyWorkflowExecutionID, eid))
 	assert.Equal(t, 1, beholderTester.Len(t, platform.KeyWorkflowExecutionID, eid2))
 }
+
+type mockBillingClient struct {
+	mock.Mock
+}
+
+func (_m *mockBillingClient) SubmitWorkflowReceipt(ctx context.Context, req *billing.SubmitWorkflowReceiptRequest) (*billing.SubmitWorkflowReceiptResponse, error) {
+	args := _m.Called(ctx, req)
+
+	var a0 *billing.SubmitWorkflowReceiptResponse
+	if arg, ok := args.Get(0).(*billing.SubmitWorkflowReceiptResponse); ok {
+		a0 = arg
+	}
+
+	return a0, args.Error(1)
+}
diff --git a/core/services/workflows/events.go b/core/services/workflows/events.go
index 78330b441bd..276317fb5e4 100644
--- a/core/services/workflows/events.go
+++ b/core/services/workflows/events.go
@@ -19,4 +19,9 @@ const (
 	SchemaCapabilityStarted string = "/cre-events-capability-started/v1"
 	// SchemaCapabilityFinished represents the schema for capability finished events
 	SchemaCapabilityFinished string = "/cre-events-capability-finished/v1"
+
+	MeteringReportSchema string = "github.com/smartcontractkit/chainlink-common/pkg/metering/pb/metering.proto"
+	MeteringReportDomain string = "platform"
+	MeteringReportEntity string = "MeteringReport"
+	MeteringProtoPkg     string = "pb"
 )
diff --git a/core/services/workflows/metering.go b/core/services/workflows/metering.go
index 0d9a06274e6..609c17cacf0 100644
--- a/core/services/workflows/metering.go
+++ b/core/services/workflows/metering.go
@@ -6,16 +6,9 @@ import (
 	"sync"
 
 	"github.com/shopspring/decimal"
-	"google.golang.org/protobuf/proto"
 
 	"github.com/smartcontractkit/chainlink-common/pkg/metering/pb"
-)
-
-const (
-	MeteringReportSchema string = "github.com/smartcontractkit/chainlink-common/pkg/capabilities/pb/capabilities.proto"
-	MeteringReportDomain string = "platform"
-	MeteringReportEntity string = "MeteringReport"
-	MeteringProtoPkg     string = "pb"
+	workflowpb "github.com/smartcontractkit/chainlink-common/pkg/workflows/events/pb"
 )
 
 type MeteringReportStepRef string
@@ -147,9 +140,10 @@ func (r *MeteringReport) SetStep(ref MeteringReportStepRef, steps []MeteringRepo
 	return nil
 }
 
-func (r *MeteringReport) Message() proto.Message {
+func (r *MeteringReport) Message() *pb.MeteringReport {
 	protoReport := &pb.MeteringReport{
-		Steps: map[string]*pb.MeteringReportStep{},
+		Steps:    map[string]*pb.MeteringReportStep{},
+		Metadata: &workflowpb.WorkflowMetadata{},
 	}
 
 	for key, step := range r.steps {
@@ -170,20 +164,6 @@ func (r *MeteringReport) Message() proto.Message {
 	return protoReport
 }
 
-type MessageDescription struct {
-	Schema string
-	Domain string
-	Entity string
-}
-
-func (r *MeteringReport) Description() MessageDescription {
-	return MessageDescription{
-		Schema: MeteringReportSchema,
-		Domain: MeteringReportDomain,
-		Entity: MeteringReportEntity,
-	}
-}
-
 // MeterReports is a concurrency-safe wrapper around map[string]*MeteringReport.
 type MeterReports struct {
 	mu           sync.RWMutex
diff --git a/core/services/workflows/monitoring.go b/core/services/workflows/monitoring.go
index fcee7dea442..83271ef37be 100644
--- a/core/services/workflows/monitoring.go
+++ b/core/services/workflows/monitoring.go
@@ -171,7 +171,7 @@ func initMonitoringResources() (em *engineMetrics, err error) {
 
 	em.workflowMissingMeteringReport, err = beholder.GetMeter().Int64Counter("platform_engine_workflow_missing_metering_report")
 	if err != nil {
-		return nil, fmt.Errorf("failed to register workflow metering missing gauge: %w", err)
+		return nil, fmt.Errorf("failed to register workflow metering missing counter: %w", err)
 	}
 
 	return em, nil


Reviews:
user: login: patrickhuie19
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T18:28:12Z
user: login: patrickhuie19
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T18:29:32Z
user: login: patrickhuie19
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T18:30:43Z
user: login: patrickhuie19
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T18:34:12Z
user: login: EasterTheBunny
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T13:44:50Z
user: login: EasterTheBunny
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T13:49:00Z
user: login: EasterTheBunny
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T14:36:35Z
user: login: patrickhuie19
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T17:41:21Z
user: login: patrickhuie19
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T14:17:51Z
user: login: EasterTheBunny
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T14:21:12Z
user: login: patrickhuie19
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T14:22:01Z
user: login: george-dorin
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T15:59:37Z
user: login: cedric-cordenier
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T16:00:34Z
user: login: EasterTheBunny
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T16:17:07Z

Review comments:
diff_hunk: @@ -661,6 +661,21 @@ func (e *Engine) finishExecution(ctx context.Context, cma custmsg.MessageEmitter
 		return fmt.Errorf("failed to mark execution as finished: %w", err)
 	}
 
+	report, exists := e.meterReports.Get(executionID)
+	if exists {
+		// send metering report to beholder
+		if err = e.emitMeteringReport(ctx, report, e.workflow.name.String(), e.workflow.id, executionID); err != nil {
+			e.metrics.with(platform.KeyWorkflowID, e.workflow.id, platform.KeyWorkflowExecutionID, executionID).updateWorkflowMissingMeteringReport(ctx, 0)
+			l.Warn(fmt.Sprintf("metering report send to beholder error %s", err))
+		}
+
+		// send metering report to billing if billing client is not nil
path: core/services/workflows/engine.go
user: login: patrickhuie19
body: /nit but I'd consider doing the nil check for the billing client here, so that all sendMeteringReportToBilling does is send the report
created_at: 2025-04-09T18:28:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -384,8 +372,15 @@ func TestEngineWithHardcodedWorkflow(t *testing.T) {
 		t,
 		reg,
 		hardcodedWorkflow,
+		func(cfg *Config) {
+			cfg.BillingClient = mBillingClient
+		},
 	)
 
+	mBillingClient.On("SubmitWorkflowReceipt", mock.Anything, mock.MatchedBy(func(req *billing.SubmitWorkflowReceiptRequest) bool {
+		return req != nil && req.WorkflowId != "" && req.WorkflowExecutionId != ""
+	})).Return(&billing.SubmitWorkflowReceiptResponse{Success: true}, nil).Times(4) // TODO: not sure how many times or should use Maybe
path: core/services/workflows/engine_test.go
user: login: patrickhuie19
body: Huh - I'd expect to see the Metering Report once
created_at: 2025-04-09T18:29:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -40,7 +40,7 @@ type engineMetrics struct {
 	workflowErrorDurationSeconds     metric.Int64Histogram
 	workflowTimeoutDurationSeconds   metric.Int64Histogram
 	workflowStepDurationSeconds      metric.Int64Histogram
-	workflowMissingMeteringReport    metric.Int64Counter
+	workflowMissingMeteringReport    metric.Int64Gauge
path: core/services/workflows/monitoring.go
user: login: patrickhuie19
body: sorry for the churn, but Cedric and I discussed last week and we'd prefer to use a counter here. 
created_at: 2025-04-09T18:30:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1177,6 +1197,46 @@ func (e *Engine) heartbeat(ctx context.Context) {
 	}
 }
 
+func (e *Engine) sendMeteringReportToBilling(ctx context.Context, report *MeteringReport, workflowID string, executionID string) error {
+	if e.billingClient != nil {
+		req := billing.SubmitWorkflowReceiptRequest{
+			AccountId:           "", // TODO: not sure what to do with the AccountID
+			WorkflowId:          workflowID,
+			WorkflowExecutionId: executionID,
+			Metering:            report.toProto(),
+		}
+
+		resp, err := e.billingClient.SubmitWorkflowReceipt(ctx, &req)
+		if err != nil {
+			return err
+		}
+
+		if resp == nil || !resp.Success {
+			return errors.New("failed to submit workflow receipt")
+		}
+	}
+
+	return nil
+}
+
+// emitMeteringReport is separate from `emitCustomMessage` because the workflow and execution id are included as attrs
+// and the reference entity is different.
+func (e *Engine) emitMeteringReport(ctx context.Context, report *MeteringReport, workflowName, workflowID, execID string) error {
path: core/services/workflows/engine.go
user: login: patrickhuie19
body: What about passing in the `cma` here so that you can pull in all the workflow metadata fields to the new Metadata field on the MeteringReport proto? (https://github.com/smartcontractkit/chainlink-common/pull/1113/files#diff-67b682d2a5338e8a9e55c477a131b50f888d8740984c84891d01f157196ece9eR14)
created_at: 2025-04-09T18:34:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -661,6 +661,21 @@ func (e *Engine) finishExecution(ctx context.Context, cma custmsg.MessageEmitter
 		return fmt.Errorf("failed to mark execution as finished: %w", err)
 	}
 
+	report, exists := e.meterReports.Get(executionID)
+	if exists {
+		// send metering report to beholder
+		if err = e.emitMeteringReport(ctx, report, e.workflow.name.String(), e.workflow.id, executionID); err != nil {
+			e.metrics.with(platform.KeyWorkflowID, e.workflow.id, platform.KeyWorkflowExecutionID, executionID).updateWorkflowMissingMeteringReport(ctx, 0)
+			l.Warn(fmt.Sprintf("metering report send to beholder error %s", err))
+		}
+
+		// send metering report to billing if billing client is not nil
path: core/services/workflows/engine.go
user: login: EasterTheBunny
body: The only issue I have with that is that you get a lot of nested if blocks within `finishExecution` and the nil test is still scoped to the concern of sending the report to billing.
created_at: 2025-04-10T13:44:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -384,8 +372,15 @@ func TestEngineWithHardcodedWorkflow(t *testing.T) {
 		t,
 		reg,
 		hardcodedWorkflow,
+		func(cfg *Config) {
+			cfg.BillingClient = mBillingClient
+		},
 	)
 
+	mBillingClient.On("SubmitWorkflowReceipt", mock.Anything, mock.MatchedBy(func(req *billing.SubmitWorkflowReceiptRequest) bool {
+		return req != nil && req.WorkflowId != "" && req.WorkflowExecutionId != ""
+	})).Return(&billing.SubmitWorkflowReceiptResponse{Success: true}, nil).Times(4) // TODO: not sure how many times or should use Maybe
path: core/services/workflows/engine_test.go
user: login: EasterTheBunny
body: I was thinking of another test case involving multiple nodes (which will send `n` reports for `n` nodes). I removed the `Times` assertion and added a line to verify that the client is only called once.
created_at: 2025-04-10T13:49:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1177,6 +1197,46 @@ func (e *Engine) heartbeat(ctx context.Context) {
 	}
 }
 
+func (e *Engine) sendMeteringReportToBilling(ctx context.Context, report *MeteringReport, workflowID string, executionID string) error {
+	if e.billingClient != nil {
+		req := billing.SubmitWorkflowReceiptRequest{
+			AccountId:           "", // TODO: not sure what to do with the AccountID
+			WorkflowId:          workflowID,
+			WorkflowExecutionId: executionID,
+			Metering:            report.toProto(),
+		}
+
+		resp, err := e.billingClient.SubmitWorkflowReceipt(ctx, &req)
+		if err != nil {
+			return err
+		}
+
+		if resp == nil || !resp.Success {
+			return errors.New("failed to submit workflow receipt")
+		}
+	}
+
+	return nil
+}
+
+// emitMeteringReport is separate from `emitCustomMessage` because the workflow and execution id are included as attrs
+// and the reference entity is different.
+func (e *Engine) emitMeteringReport(ctx context.Context, report *MeteringReport, workflowName, workflowID, execID string) error {
path: core/services/workflows/engine.go
user: login: EasterTheBunny
body: I updated common to pull this in and the only strangeness it added was two different metadata types that are almost identical. One is in core and the other in common.
created_at: 2025-04-10T14:36:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1177,6 +1199,28 @@ func (e *Engine) heartbeat(ctx context.Context) {
 	}
 }
 
+func (e *Engine) sendMeteringReportToBilling(ctx context.Context, report *MeteringReport, workflowID string, executionID string) error {
+	if e.billingClient != nil {
+		req := billing.SubmitWorkflowReceiptRequest{
+			AccountId:           "", // TODO: not sure what to do with the AccountID
path: core/services/workflows/engine.go
user: login: patrickhuie19
body: WorkflowOwner
created_at: 2025-04-10T17:41:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -228,3 +208,5 @@ func (s *MeterReports) Len() int {
 
 	return len(s.meterReports)
 }
+
+type WorkflowReceipt struct{}
path: core/services/workflows/metering.go
user: login: patrickhuie19
body: /nit where is this used?
created_at: 2025-04-17T14:17:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -228,3 +208,5 @@ func (s *MeterReports) Len() int {
 
 	return len(s.meterReports)
 }
+
+type WorkflowReceipt struct{}
path: core/services/workflows/metering.go
user: login: EasterTheBunny
body: Nowhere it seems. I'll remove it
created_at: 2025-04-17T14:21:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -661,6 +664,21 @@ func (e *Engine) finishExecution(ctx context.Context, cma custmsg.MessageEmitter
 		return fmt.Errorf("failed to mark execution as finished: %w", err)
 	}
 
+	report, exists := e.meterReports.Get(executionID)
path: core/services/workflows/engine.go
user: login: cedric-cordenier
body: @EasterTheBunny Would we ever expect exists to be false? That seems like a big error that we would want to be alerted on right away, or am I missing something?
created_at: 2025-04-17T16:00:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -661,6 +664,21 @@ func (e *Engine) finishExecution(ctx context.Context, cma custmsg.MessageEmitter
 		return fmt.Errorf("failed to mark execution as finished: %w", err)
 	}
 
+	report, exists := e.meterReports.Get(executionID)
path: core/services/workflows/engine.go
user: login: EasterTheBunny
body: The only way for it to be false is if the code to create it was removed. This initialization is in `startExecution` and is called before everything else. I suppose on a node restart, since the metering detail is not persisted yet, there might be a case where the report doesn't exist. However, we have decided to punt on that problem for now. Since metering reports are critical yet, this `exists` check should be safe enough for now.
created_at: 2025-04-17T16:17:07Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-10T14:45:37Z
body: # Flakeguard Summary

Ran new or updated tests between `develop` and 3d6ad75df28df7e26157e01a3015a3475753e57c (`CRE-294/send-metering-report-to-billing`).

[View Flaky Detector Details](https://github.com/smartcontractkit/chainlink/actions/runs/14383177716) | [Compare Changes](https://github.com/smartcontractkit/chainlink/compare/develop...3d6ad75df28df7e26157e01a3015a3475753e57c#files_bucket)

## Found Flaky Tests :x:
<details>
<summary>1 Results</summary>

| **Name**                                      | **Pass Ratio** | **Panicked?** | **Timed Out?** | **Race?** | **Runs** | **Successes** | **Failures** | **Skips** | **Package**                                                      | **Package Panicked?** | **Avg Duration** | **Code Owners** |
|-----------------------------------------------|----------------|---------------|----------------|-----------|----------|---------------|--------------|-----------|------------------------------------------------------------------|-----------------------|------------------|-----------------|
| UnableToAttributePanicToTestPleaseInvestigate | 0%             | true          | false          | false     | 3        | 0             | 3            | 0         | github.com/smartcontractkit/chainlink/v2/core/services/workflows | true                  | 0s               | UNKNOWN         |
</details>

## Artifacts

For detailed logs of the failed tests, please refer to the artifact [failed-test-results-with-logs.json](https://github.com/smartcontractkit/chainlink/actions/runs/14383177716/artifacts/2920358634).

author_association: CONTRIBUTOR
user: login: cl-sonarqube-production[bot]
created_at: 2025-04-17T14:32:56Z
body: ## [![Quality Gate failed](https://raw.githubusercontent.com/SonarSource/sonarqube-static-resources/master/v104/checks/QualityGateBadge/failed-16px.png 'Quality Gate failed')](https://sonarqube.main.prod.cldev.sh/dashboard?id=smartcontractkit_chainlink&pullRequest=16994) **Quality Gate failed**  
Failed conditions  
[![](https://raw.githubusercontent.com/SonarSource/sonarqube-static-resources/master/v104/common/failed-16px.png '')](https://sonarqube.main.prod.cldev.sh/component_measures?id=smartcontractkit_chainlink&pullRequest=16994&metric=new_duplicated_lines_density&view=list) [18.7% Duplication on New Code](https://sonarqube.main.prod.cldev.sh/component_measures?id=smartcontractkit_chainlink&pullRequest=16994&metric=new_duplicated_lines_density&view=list) (required ≤ 10%)  
  
[See analysis details on SonarQube](https://sonarqube.main.prod.cldev.sh/dashboard?id=smartcontractkit_chainlink&pullRequest=16994)


author_association: NONE