diff --git a/core/config/app_config.go b/core/config/app_config.go
index 107cf7def88..29dbb1f5acf 100644
--- a/core/config/app_config.go
+++ b/core/config/app_config.go
@@ -64,6 +64,7 @@ type AppConfig interface {
CCV() CCV
Billing() Billing
BridgeStatusReporter() BridgeStatusReporter
+ JobSpecReporter() JobSpecReporter
Sharding() Sharding
LOOPP() LOOPP
}
diff --git a/core/config/docs/core.toml b/core/config/docs/core.toml
index 31a0b510fbd..a860cf706de 100644
--- a/core/config/docs/core.toml
+++ b/core/config/docs/core.toml
@@ -949,6 +949,17 @@ IgnoreInvalidBridges = true # Default
# IgnoreJoblessBridges skips bridges that have no associated jobs.
IgnoreJoblessBridges = false # Default
+# JobSpecReporter holds settings for the Job Spec Reporter service, which periodically emits job spec telemetry.
+[JobSpecReporter]
+# Enabled enables the Job Spec Reporter service.
+Enabled = false # Default
+# PollingInterval is how often to emit a heartbeat event for each tracked job.
+PollingInterval = "1h" # Default
+# EnabledOCR2PluginTypes restricts OCR2 telemetry to jobs with these plugin types.
+EnabledOCR2PluginTypes = ["median"] # Default
+# EmitNonOCR2Jobs emits telemetry for non-OCR2 job types (OCR1, Flux Monitor, Keeper).
+EmitNonOCR2Jobs = false # Default
+
[CRE]
# UseLocalTimeProvider should be set true if the DON Time OCR Plugin is not running
UseLocalTimeProvider = true # Default
diff --git a/core/config/job_spec_reporter_config.go b/core/config/job_spec_reporter_config.go
new file mode 100644
index 00000000000..aca5c9de272
--- /dev/null
+++ b/core/config/job_spec_reporter_config.go
@@ -0,0 +1,14 @@
+package config
+
+import "time"
+
+type JobSpecReporter interface {
+ Enabled() bool
+ PollingInterval() time.Duration
+ // EnabledOCR2PluginTypes is the allowlist of OCR2 plugin types to emit for
+ // (e.g. "median", "ocr2keeper"). An empty slice means emit for all types.
+ EnabledOCR2PluginTypes() []string
+ // EmitNonOCR2Jobs toggles emission for non-OCR2 job types (VRF, Keeper,
+ // Functions, CCIP, Workflow, …). Defaults to false.
+ EmitNonOCR2Jobs() bool
+}
diff --git a/core/config/toml/types.go b/core/config/toml/types.go
index af9cf192255..0f10dc9b6a8 100644
--- a/core/config/toml/types.go
+++ b/core/config/toml/types.go
@@ -9,6 +9,7 @@ import (
"reflect"
"regexp"
"strings"
+ "time"
"github.com/google/uuid"
"go.uber.org/zap/zapcore"
@@ -65,6 +66,7 @@ type Core struct {
CRE CreConfig `toml:",omitempty"`
Billing Billing `toml:",omitempty"`
BridgeStatusReporter BridgeStatusReporter `toml:",omitempty"`
+ JobSpecReporter JobSpecReporter `toml:",omitempty"`
Sharding Sharding `toml:",omitempty"`
LOOPP LOOPP `toml:",omitempty"`
}
@@ -112,6 +114,7 @@ func (c *Core) SetFrom(f *Core) {
c.CRE.setFrom(&f.CRE)
c.Billing.setFrom(&f.Billing)
c.BridgeStatusReporter.setFrom(&f.BridgeStatusReporter)
+ c.JobSpecReporter.setFrom(&f.JobSpecReporter)
c.Sharding.setFrom(&f.Sharding)
c.LOOPP.setFrom(&f.LOOPP)
@@ -3086,6 +3089,55 @@ func (e *BridgeStatusReporter) ValidateConfig() error {
return nil
}
+type JobSpecReporter struct {
+ Enabled *bool
+ PollingInterval *commonconfig.Duration
+ EnabledOCR2PluginTypes *[]string
+ EmitNonOCR2Jobs *bool
+}
+
+func (e *JobSpecReporter) setFrom(f *JobSpecReporter) {
+ if f.Enabled != nil {
+ e.Enabled = f.Enabled
+ }
+ if f.PollingInterval != nil {
+ e.PollingInterval = f.PollingInterval
+ }
+ if f.EnabledOCR2PluginTypes != nil {
+ e.EnabledOCR2PluginTypes = f.EnabledOCR2PluginTypes
+ }
+ if f.EmitNonOCR2Jobs != nil {
+ e.EmitNonOCR2Jobs = f.EmitNonOCR2Jobs
+ }
+}
+
+func (e *JobSpecReporter) ValidateConfig() error {
+ if e.Enabled == nil || !*e.Enabled {
+ return nil
+ }
+
+ if e.PollingInterval == nil {
+ defaultInterval := commonconfig.MustNewDuration(time.Hour)
+ e.PollingInterval = defaultInterval
+ }
+
+ if e.PollingInterval.Duration() < config.MinimumPollingInterval {
+ return configutils.ErrInvalid{Name: "PollingInterval", Value: e.PollingInterval.Duration(), Msg: "must be greater than or equal to: " + config.MinimumPollingInterval.String()}
+ }
+
+ if e.EnabledOCR2PluginTypes == nil {
+ defaultTypes := []string{"median"}
+ e.EnabledOCR2PluginTypes = &defaultTypes
+ }
+
+ if e.EmitNonOCR2Jobs == nil {
+ defaultEmitNonOCR2 := false
+ e.EmitNonOCR2Jobs = &defaultEmitNonOCR2
+ }
+
+ return nil
+}
+
type JobDistributor struct {
DisplayName *string
}
diff --git a/core/scripts/go.mod b/core/scripts/go.mod
index 024dcdd64dc..df562344156 100644
--- a/core/scripts/go.mod
+++ b/core/scripts/go.mod
@@ -506,6 +506,7 @@ require (
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/heartbeat v0.0.0-20260115142640-f6b99095c12e // indirect
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/message-discovery v0.0.0-20251211142334-5c3421fe2c8d // indirect
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d // indirect
+ github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a // indirect
github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b // indirect
github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260319180422-b5808c964785 // indirect
github.com/smartcontractkit/chainlink-protos/orchestrator v0.10.0 // indirect
diff --git a/core/scripts/go.sum b/core/scripts/go.sum
index 0359a597ff4..1ed268bdc8d 100644
--- a/core/scripts/go.sum
+++ b/core/scripts/go.sum
@@ -1692,6 +1692,8 @@ github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d/go.mod h1:5JdppgngCOUS76p61zCinSCgOhPeYQ+OcDUuome5THQ=
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260420204255-a3f3bdd56877 h1:6UueUIbck1Ogarm9rm/9TS6b09mKgMmx+YE8XFg63AQ=
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260420204255-a3f3bdd56877/go.mod h1:Jqt53s27Tr0jDl8mdBXg1xhu6F8Fci8JOuq43tgHOM8=
+github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a h1:UPejHeV2qjuZxc9TLa6d0q1KbC9oW29eBWFSynVvXv8=
+github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a/go.mod h1:vL1bDgPSJjV0EqHYs4dDlR+EEE0cJchgvGLYXhwIjXY=
github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0 h1:q+VDPcxWrj5k9QizSYfUOSMnDH3Sd5HvbPguZOgfXTY=
github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0/go.mod h1:/dVVLXrsp+V0AbcYGJo3XMzKg3CkELsweA/TTopCsKE=
github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b h1:QuI6SmQFK/zyUlVWEf0GMkiUYBPY4lssn26nKSd/bOM=
diff --git a/core/services/chainlink/application.go b/core/services/chainlink/application.go
index b9f70144d12..57b190a2c63 100644
--- a/core/services/chainlink/application.go
+++ b/core/services/chainlink/application.go
@@ -7,6 +7,7 @@ import (
"fmt"
"math/big"
"net/http"
+ "os"
"strconv"
"sync"
"time"
@@ -71,6 +72,7 @@ import (
"github.com/smartcontractkit/chainlink/v2/core/services/keystore"
"github.com/smartcontractkit/chainlink/v2/core/services/llo/retirement"
"github.com/smartcontractkit/chainlink/v2/core/services/nodestatusreporter/bridgestatus"
+ "github.com/smartcontractkit/chainlink/v2/core/services/nodestatusreporter/jobspec"
"github.com/smartcontractkit/chainlink/v2/core/services/ocr"
"github.com/smartcontractkit/chainlink/v2/core/services/ocr2"
"github.com/smartcontractkit/chainlink/v2/core/services/ocrbootstrap"
@@ -800,8 +802,9 @@ func NewApplication(ctx context.Context, opts ApplicationOpts) (Application, err
srvcs = append(srvcs, jobSpawner, pipelineRunner)
var feedsService feeds.Service
+ var feedsORM feeds.ORM
if cfg.Feature().FeedsManager() {
- feedsORM := feeds.NewORM(opts.DS, globalLogger)
+ feedsORM = feeds.NewORM(opts.DS, globalLogger)
feedsService = feeds.NewService(
feedsORM,
jobORM,
@@ -824,6 +827,19 @@ func NewApplication(ctx context.Context, opts ApplicationOpts) (Application, err
feedsService = &feeds.NullService{}
}
+ hostname, _ := os.Hostname()
+ jobSpecReporter := jobspec.NewJobSpecReporter(
+ cfg.JobSpecReporter(),
+ jobSpawner,
+ feedsORM,
+ beholder.GetEmitter(),
+ csaPubKeyHex,
+ static.Version,
+ hostname,
+ globalLogger,
+ )
+ srvcs = append(srvcs, jobSpecReporter)
+
for _, s := range srvcs {
if s == nil {
panic("service unexpectedly nil")
diff --git a/core/services/chainlink/config_general.go b/core/services/chainlink/config_general.go
index ed18c6ca7a0..08289231dee 100644
--- a/core/services/chainlink/config_general.go
+++ b/core/services/chainlink/config_general.go
@@ -596,6 +596,10 @@ func (g *generalConfig) BridgeStatusReporter() coreconfig.BridgeStatusReporter {
return &bridgeStatusReporterConfig{c: g.c.BridgeStatusReporter}
}
+func (g *generalConfig) JobSpecReporter() coreconfig.JobSpecReporter {
+ return &jobSpecReporterConfig{c: g.c.JobSpecReporter}
+}
+
func (g *generalConfig) Sharding() coreconfig.Sharding {
return &shardingConfig{s: g.c.Sharding}
}
diff --git a/core/services/chainlink/config_job_spec_reporter.go b/core/services/chainlink/config_job_spec_reporter.go
new file mode 100644
index 00000000000..791cbaaa1f0
--- /dev/null
+++ b/core/services/chainlink/config_job_spec_reporter.go
@@ -0,0 +1,42 @@
+package chainlink
+
+import (
+ "time"
+
+ "github.com/smartcontractkit/chainlink/v2/core/config"
+ "github.com/smartcontractkit/chainlink/v2/core/config/toml"
+)
+
+var _ config.JobSpecReporter = (*jobSpecReporterConfig)(nil)
+
+type jobSpecReporterConfig struct {
+ c toml.JobSpecReporter
+}
+
+func (e *jobSpecReporterConfig) Enabled() bool {
+ if e.c.Enabled == nil {
+ return false
+ }
+ return *e.c.Enabled
+}
+
+func (e *jobSpecReporterConfig) PollingInterval() time.Duration {
+ if e.c.PollingInterval == nil {
+ return time.Hour
+ }
+ return e.c.PollingInterval.Duration()
+}
+
+func (e *jobSpecReporterConfig) EnabledOCR2PluginTypes() []string {
+ if e.c.EnabledOCR2PluginTypes == nil {
+ return []string{"median"}
+ }
+ return *e.c.EnabledOCR2PluginTypes
+}
+
+func (e *jobSpecReporterConfig) EmitNonOCR2Jobs() bool {
+ if e.c.EmitNonOCR2Jobs == nil {
+ return false
+ }
+ return *e.c.EmitNonOCR2Jobs
+}
diff --git a/core/services/chainlink/config_test.go b/core/services/chainlink/config_test.go
index 87ef592f7c2..f8dd91a41fe 100644
--- a/core/services/chainlink/config_test.go
+++ b/core/services/chainlink/config_test.go
@@ -678,6 +678,13 @@ func TestConfig_Marshal(t *testing.T) {
IgnoreInvalidBridges: ptr(true),
IgnoreJoblessBridges: ptr(false),
}
+ enabledOCR2PluginTypes := []string{"median"}
+ full.JobSpecReporter = toml.JobSpecReporter{
+ Enabled: ptr(true),
+ PollingInterval: commoncfg.MustNewDuration(time.Hour),
+ EnabledOCR2PluginTypes: &enabledOCR2PluginTypes,
+ EmitNonOCR2Jobs: ptr(false),
+ }
full.Sharding = toml.Sharding{
ShardingEnabled: ptr(false),
ArbiterPort: ptr[uint16](9876),
diff --git a/core/services/chainlink/mocks/general_config.go b/core/services/chainlink/mocks/general_config.go
index d6d602c9899..60ba7b2c281 100644
--- a/core/services/chainlink/mocks/general_config.go
+++ b/core/services/chainlink/mocks/general_config.go
@@ -1296,6 +1296,53 @@ func (_c *GeneralConfig_JobPipeline_Call) RunAndReturn(run func() config.JobPipe
return _c
}
+// JobSpecReporter provides a mock function with no fields
+func (_m *GeneralConfig) JobSpecReporter() config.JobSpecReporter {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for JobSpecReporter")
+ }
+
+ var r0 config.JobSpecReporter
+ if rf, ok := ret.Get(0).(func() config.JobSpecReporter); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(config.JobSpecReporter)
+ }
+ }
+
+ return r0
+}
+
+// GeneralConfig_JobSpecReporter_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'JobSpecReporter'
+type GeneralConfig_JobSpecReporter_Call struct {
+ *mock.Call
+}
+
+// JobSpecReporter is a helper method to define mock.On call
+func (_e *GeneralConfig_Expecter) JobSpecReporter() *GeneralConfig_JobSpecReporter_Call {
+ return &GeneralConfig_JobSpecReporter_Call{Call: _e.mock.On("JobSpecReporter")}
+}
+
+func (_c *GeneralConfig_JobSpecReporter_Call) Run(run func()) *GeneralConfig_JobSpecReporter_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run()
+ })
+ return _c
+}
+
+func (_c *GeneralConfig_JobSpecReporter_Call) Return(_a0 config.JobSpecReporter) *GeneralConfig_JobSpecReporter_Call {
+ _c.Call.Return(_a0)
+ return _c
+}
+
+func (_c *GeneralConfig_JobSpecReporter_Call) RunAndReturn(run func() config.JobSpecReporter) *GeneralConfig_JobSpecReporter_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
// Keeper provides a mock function with no fields
func (_m *GeneralConfig) Keeper() config.Keeper {
ret := _m.Called()
diff --git a/core/services/chainlink/testdata/config-empty-effective.toml b/core/services/chainlink/testdata/config-empty-effective.toml
index bd00ab34ccb..53f70792803 100644
--- a/core/services/chainlink/testdata/config-empty-effective.toml
+++ b/core/services/chainlink/testdata/config-empty-effective.toml
@@ -399,6 +399,12 @@ PollingInterval = '5m0s'
IgnoreInvalidBridges = true
IgnoreJoblessBridges = false
+[JobSpecReporter]
+Enabled = false
+PollingInterval = '1h0m0s'
+EnabledOCR2PluginTypes = ['median']
+EmitNonOCR2Jobs = false
+
[Sharding]
ShardingEnabled = false
ArbiterPort = 9876
diff --git a/core/services/chainlink/testdata/config-full.toml b/core/services/chainlink/testdata/config-full.toml
index a784f98a591..34ca1644255 100644
--- a/core/services/chainlink/testdata/config-full.toml
+++ b/core/services/chainlink/testdata/config-full.toml
@@ -437,6 +437,12 @@ PollingInterval = '5m0s'
IgnoreInvalidBridges = true
IgnoreJoblessBridges = false
+[JobSpecReporter]
+Enabled = true
+PollingInterval = '1h0m0s'
+EnabledOCR2PluginTypes = ['median']
+EmitNonOCR2Jobs = false
+
[Sharding]
ShardingEnabled = false
ArbiterPort = 9876
diff --git a/core/services/chainlink/testdata/config-multi-chain-effective.toml b/core/services/chainlink/testdata/config-multi-chain-effective.toml
index ee3c20150eb..43f8bd91156 100644
--- a/core/services/chainlink/testdata/config-multi-chain-effective.toml
+++ b/core/services/chainlink/testdata/config-multi-chain-effective.toml
@@ -399,6 +399,12 @@ PollingInterval = '5m0s'
IgnoreInvalidBridges = true
IgnoreJoblessBridges = false
+[JobSpecReporter]
+Enabled = false
+PollingInterval = '1h0m0s'
+EnabledOCR2PluginTypes = ['median']
+EmitNonOCR2Jobs = false
+
[Sharding]
ShardingEnabled = false
ArbiterPort = 9876
diff --git a/core/services/feeds/mocks/orm.go b/core/services/feeds/mocks/orm.go
index 6ce30a0a78c..7b527b0727c 100644
--- a/core/services/feeds/mocks/orm.go
+++ b/core/services/feeds/mocks/orm.go
@@ -1034,6 +1034,65 @@ func (_c *ORM_GetJobProposal_Call) RunAndReturn(run func(context.Context, int64)
return _c
}
+// GetJobProposalByExternalJobID provides a mock function with given fields: ctx, externalJobID
+func (_m *ORM) GetJobProposalByExternalJobID(ctx context.Context, externalJobID uuid.UUID) (*feeds.JobProposal, error) {
+ ret := _m.Called(ctx, externalJobID)
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetJobProposalByExternalJobID")
+ }
+
+ var r0 *feeds.JobProposal
+ var r1 error
+ if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) (*feeds.JobProposal, error)); ok {
+ return rf(ctx, externalJobID)
+ }
+ if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) *feeds.JobProposal); ok {
+ r0 = rf(ctx, externalJobID)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*feeds.JobProposal)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(context.Context, uuid.UUID) error); ok {
+ r1 = rf(ctx, externalJobID)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// ORM_GetJobProposalByExternalJobID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetJobProposalByExternalJobID'
+type ORM_GetJobProposalByExternalJobID_Call struct {
+ *mock.Call
+}
+
+// GetJobProposalByExternalJobID is a helper method to define mock.On call
+// - ctx context.Context
+// - externalJobID uuid.UUID
+func (_e *ORM_Expecter) GetJobProposalByExternalJobID(ctx interface{}, externalJobID interface{}) *ORM_GetJobProposalByExternalJobID_Call {
+ return &ORM_GetJobProposalByExternalJobID_Call{Call: _e.mock.On("GetJobProposalByExternalJobID", ctx, externalJobID)}
+}
+
+func (_c *ORM_GetJobProposalByExternalJobID_Call) Run(run func(ctx context.Context, externalJobID uuid.UUID)) *ORM_GetJobProposalByExternalJobID_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(context.Context), args[1].(uuid.UUID))
+ })
+ return _c
+}
+
+func (_c *ORM_GetJobProposalByExternalJobID_Call) Return(_a0 *feeds.JobProposal, _a1 error) *ORM_GetJobProposalByExternalJobID_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *ORM_GetJobProposalByExternalJobID_Call) RunAndReturn(run func(context.Context, uuid.UUID) (*feeds.JobProposal, error)) *ORM_GetJobProposalByExternalJobID_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
// GetJobProposalByRemoteUUID provides a mock function with given fields: ctx, _a1
func (_m *ORM) GetJobProposalByRemoteUUID(ctx context.Context, _a1 uuid.UUID) (*feeds.JobProposal, error) {
ret := _m.Called(ctx, _a1)
diff --git a/core/services/feeds/orm.go b/core/services/feeds/orm.go
index c0edd00a3a3..a6b6015667f 100644
--- a/core/services/feeds/orm.go
+++ b/core/services/feeds/orm.go
@@ -40,6 +40,7 @@ type ORM interface {
DeleteProposal(ctx context.Context, id int64) error
GetJobProposal(ctx context.Context, id int64) (*JobProposal, error)
GetJobProposalByRemoteUUID(ctx context.Context, uuid uuid.UUID) (*JobProposal, error)
+ GetJobProposalByExternalJobID(ctx context.Context, externalJobID uuid.UUID) (*JobProposal, error)
ListJobProposalsByManagersIDs(ctx context.Context, ids []int64) ([]JobProposal, error)
UpdateJobProposalStatus(ctx context.Context, id int64, status JobProposalStatus) error // NEEDED?
UpsertJobProposal(ctx context.Context, jp *JobProposal) (int64, error)
@@ -432,6 +433,21 @@ AND status <> $2;
return jp, errors.Wrap(err, "GetJobProposalByRemoteUUID failed")
}
+// GetJobProposalByExternalJobID gets a non-deleted job proposal by its external job ID.
+func (o *orm) GetJobProposalByExternalJobID(ctx context.Context, externalJobID uuid.UUID) (jp *JobProposal, err error) {
+ stmt := `
+SELECT *
+FROM job_proposals
+WHERE external_job_id = $1
+AND status <> $2
+LIMIT 1;
+`
+
+ jp = new(JobProposal)
+ err = o.ds.GetContext(ctx, jp, stmt, externalJobID, JobProposalStatusDeleted)
+ return jp, errors.Wrap(err, "GetJobProposalByExternalJobID failed")
+}
+
// ListJobProposalsByManagersIDs gets job proposals by feeds managers IDs.
func (o *orm) ListJobProposalsByManagersIDs(ctx context.Context, ids []int64) ([]JobProposal, error) {
stmt := `
diff --git a/core/services/job/mocks/spawner.go b/core/services/job/mocks/spawner.go
index 28ff46461b1..22b6013d4be 100644
--- a/core/services/job/mocks/spawner.go
+++ b/core/services/job/mocks/spawner.go
@@ -348,6 +348,39 @@ func (_c *Spawner_Ready_Call) RunAndReturn(run func() error) *Spawner_Ready_Call
return _c
}
+// RegisterListener provides a mock function with given fields: l
+func (_m *Spawner) RegisterListener(l job.Listener) {
+ _m.Called(l)
+}
+
+// Spawner_RegisterListener_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegisterListener'
+type Spawner_RegisterListener_Call struct {
+ *mock.Call
+}
+
+// RegisterListener is a helper method to define mock.On call
+// - l job.Listener
+func (_e *Spawner_Expecter) RegisterListener(l interface{}) *Spawner_RegisterListener_Call {
+ return &Spawner_RegisterListener_Call{Call: _e.mock.On("RegisterListener", l)}
+}
+
+func (_c *Spawner_RegisterListener_Call) Run(run func(l job.Listener)) *Spawner_RegisterListener_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(job.Listener))
+ })
+ return _c
+}
+
+func (_c *Spawner_RegisterListener_Call) Return() *Spawner_RegisterListener_Call {
+ _c.Call.Return()
+ return _c
+}
+
+func (_c *Spawner_RegisterListener_Call) RunAndReturn(run func(job.Listener)) *Spawner_RegisterListener_Call {
+ _c.Run(run)
+ return _c
+}
+
// Start provides a mock function with given fields: _a0
func (_m *Spawner) Start(_a0 context.Context) error {
ret := _m.Called(_a0)
diff --git a/core/services/job/spawner.go b/core/services/job/spawner.go
index e883fb23b47..2b1ad5b1d5c 100644
--- a/core/services/job/spawner.go
+++ b/core/services/job/spawner.go
@@ -17,6 +17,13 @@ import (
)
type (
+ // Listener is notified when the Spawner starts or stops a job.
+ // Callbacks run asynchronously and must not block.
+ Listener interface {
+ OnJobStarted(ctx context.Context, jb Job)
+ OnJobStopped(ctx context.Context, jb Job)
+ }
+
// Spawner manages the spinning up and down of the long-running
// services that perform the work described by job specs. Each active job spec
// has 1 or more of these services associated with it.
@@ -35,6 +42,10 @@ type (
// NOTE: Prefer to use CreateJob, this is only publicly exposed for use in tests
// to start a job that was previously manually inserted into DB
StartService(ctx context.Context, spec Job) error
+
+ // RegisterListener adds l to the set of listeners notified on job start/stop.
+ // Safe to call before or after Start.
+ RegisterListener(l Listener)
}
Checker interface {
@@ -52,6 +63,9 @@ type (
activeJobsMu sync.RWMutex
lggr logger.Logger
+ listeners []Listener
+ listenersMu sync.RWMutex
+
chStop services.StopChan
lbDependentAwaiters []utils.DependentAwaiter
}
@@ -274,6 +288,7 @@ func (js *spawner) CreateJob(ctx context.Context, ds sqlutil.DataSource, jb *Job
js.lggr.Errorw("Error starting job services", "type", jb.Type, "jobID", jb.ID, "err", err)
} else {
js.lggr.Infow("Started job services", "type", jb.Type, "jobID", jb.ID)
+ js.notifyStarted(*jb)
}
delegate.AfterJobCreated(*jb)
@@ -340,6 +355,7 @@ func (js *spawner) DeleteJob(ctx context.Context, ds sqlutil.DataSource, jobID i
if exists {
// Stop the service and remove the job from memory, which will always happen even if closing the services fail.
js.stopService(jobID)
+ js.notifyStopped(aj.spec)
}
lggr.Infow("Stopped and deleted job")
@@ -357,6 +373,47 @@ func (js *spawner) ActiveJobs() map[int32]Job {
return m
}
+func (js *spawner) RegisterListener(l Listener) {
+ js.listenersMu.Lock()
+ defer js.listenersMu.Unlock()
+ js.listeners = append(js.listeners, l)
+}
+
+func (js *spawner) notifyStarted(jb Job) {
+ js.dispatchToListeners(func(ctx context.Context, l Listener) { l.OnJobStarted(ctx, jb) })
+}
+
+func (js *spawner) notifyStopped(jb Job) {
+ js.dispatchToListeners(func(ctx context.Context, l Listener) { l.OnJobStopped(ctx, jb) })
+}
+
+// dispatchToListeners fans out fn to every registered listener in a single
+// best-effort goroutine. Panics are recovered so a faulty listener cannot
+// bring the spawner down.
+func (js *spawner) dispatchToListeners(fn func(context.Context, Listener)) {
+ js.listenersMu.RLock()
+ ls := make([]Listener, len(js.listeners))
+ copy(ls, js.listeners)
+ js.listenersMu.RUnlock()
+
+ if len(ls) == 0 {
+ return
+ }
+
+ ctx, cancel := js.chStop.NewCtx()
+ go func() {
+ defer cancel()
+ defer func() {
+ if r := recover(); r != nil {
+ js.lggr.Errorw("Panic in job spawner listener", "recover", r)
+ }
+ }()
+ for _, l := range ls {
+ fn(ctx, l)
+ }
+ }()
+}
+
func (js *spawner) activeJobIDs() []int32 {
js.activeJobsMu.RLock()
defer js.activeJobsMu.RUnlock()
diff --git a/core/services/nodestatusreporter/jobspec/events/emit.go b/core/services/nodestatusreporter/jobspec/events/emit.go
new file mode 100644
index 00000000000..d7500160773
--- /dev/null
+++ b/core/services/nodestatusreporter/jobspec/events/emit.go
@@ -0,0 +1,34 @@
+package events
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "google.golang.org/protobuf/proto"
+
+ "github.com/smartcontractkit/chainlink-common/pkg/beholder"
+)
+
+func EmitJobSpecEvent(ctx context.Context, emitter beholder.Emitter, event *JobSpecEvent) error {
+ if event.Timestamp == "" {
+ event.Timestamp = time.Now().Format(time.RFC3339Nano)
+ }
+
+ eventBytes, err := proto.Marshal(event)
+ if err != nil {
+ return fmt.Errorf("failed to marshal JobSpecEvent: %w", err)
+ }
+
+ err = emitter.Emit(ctx, eventBytes,
+ "source", EventSource,
+ "beholder_data_schema", SchemaJobSpec,
+ "beholder_domain", BeholderDomain,
+ "beholder_entity", fmt.Sprintf("%s.%s", ProtoPkg, JobSpecEventEntity),
+ )
+ if err != nil {
+ return fmt.Errorf("failed to emit JobSpecEvent: %w", err)
+ }
+
+ return nil
+}
diff --git a/core/services/nodestatusreporter/jobspec/events/emit_test.go b/core/services/nodestatusreporter/jobspec/events/emit_test.go
new file mode 100644
index 00000000000..5902a6c14e5
--- /dev/null
+++ b/core/services/nodestatusreporter/jobspec/events/emit_test.go
@@ -0,0 +1,67 @@
+package events_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/proto"
+
+ "github.com/smartcontractkit/chainlink-common/pkg/beholder"
+ "github.com/smartcontractkit/chainlink-common/pkg/beholder/beholdertest"
+
+ "github.com/smartcontractkit/chainlink/v2/core/services/nodestatusreporter/jobspec/events"
+)
+
+func TestEmitJobSpecEvent_RoundTrip(t *testing.T) {
+ // NewObserver installs the global beholder client; GetEmitter returns the one to emit through.
+ observer := beholdertest.NewObserver(t)
+ emitter := beholder.GetEmitter()
+
+ event := &events.JobSpecEvent{
+ ExternalJobId: "test-job-id",
+ InternalJobId: 42,
+ Name: "test-job",
+ JobType: "offchainreporting2",
+ EmissionTrigger: events.EmissionTrigger_EMISSION_TRIGGER_HEARTBEAT,
+ }
+
+ err := events.EmitJobSpecEvent(context.Background(), emitter, event)
+ require.NoError(t, err)
+
+ msgs := observer.Messages(t, "beholder_entity", events.ProtoPkg+"."+events.JobSpecEventEntity)
+ require.Len(t, msgs, 1)
+
+ msg := msgs[0]
+ require.Equal(t, events.SchemaJobSpec, msg.Attrs["beholder_data_schema"])
+ require.Equal(t, events.BeholderDomain, msg.Attrs["beholder_domain"])
+ require.Equal(t, events.EventSource, msg.Attrs["source"])
+
+ var decoded events.JobSpecEvent
+ require.NoError(t, proto.Unmarshal(msg.Body, &decoded))
+ require.Equal(t, "test-job-id", decoded.ExternalJobId)
+ require.Equal(t, int32(42), decoded.InternalJobId)
+ require.Equal(t, "test-job", decoded.Name)
+ require.Equal(t, events.EmissionTrigger_EMISSION_TRIGGER_HEARTBEAT, decoded.EmissionTrigger)
+ require.NotEmpty(t, decoded.Timestamp)
+}
+
+func TestEmitJobSpecEvent_SetsTimestampIfEmpty(t *testing.T) {
+ observer := beholdertest.NewObserver(t)
+ emitter := beholder.GetEmitter()
+
+ event := &events.JobSpecEvent{
+ ExternalJobId: "ts-test",
+ }
+ require.Empty(t, event.Timestamp)
+
+ err := events.EmitJobSpecEvent(context.Background(), emitter, event)
+ require.NoError(t, err)
+
+ msgs := observer.Messages(t, "beholder_entity", events.ProtoPkg+"."+events.JobSpecEventEntity)
+ require.Len(t, msgs, 1)
+
+ var decoded events.JobSpecEvent
+ require.NoError(t, proto.Unmarshal(msgs[0].Body, &decoded))
+ require.NotEmpty(t, decoded.Timestamp)
+}
diff --git a/core/services/nodestatusreporter/jobspec/events/generate.go b/core/services/nodestatusreporter/jobspec/events/generate.go
new file mode 100644
index 00000000000..28254e8a0a0
--- /dev/null
+++ b/core/services/nodestatusreporter/jobspec/events/generate.go
@@ -0,0 +1,3 @@
+package events
+
+// Job spec protobufs are generated in github.com/smartcontractkit/chainlink-protos/data-feeds.
diff --git a/core/services/nodestatusreporter/jobspec/events/job_spec.pb.go b/core/services/nodestatusreporter/jobspec/events/job_spec.pb.go
new file mode 100644
index 00000000000..7e11e16520e
--- /dev/null
+++ b/core/services/nodestatusreporter/jobspec/events/job_spec.pb.go
@@ -0,0 +1,27 @@
+// Code generated by chainlink-protos import shim. DO NOT EDIT.
+// source: job_spec.proto
+
+package events
+
+import job_specv1 "github.com/smartcontractkit/chainlink-protos/data-feeds/job_spec/v1"
+
+type EmissionTrigger = job_specv1.EmissionTrigger
+
+const (
+ EmissionTrigger_EMISSION_TRIGGER_UNSPECIFIED = job_specv1.EmissionTrigger_EMISSION_TRIGGER_UNSPECIFIED
+ EmissionTrigger_EMISSION_TRIGGER_HEARTBEAT = job_specv1.EmissionTrigger_EMISSION_TRIGGER_HEARTBEAT
+ EmissionTrigger_EMISSION_TRIGGER_CREATE = job_specv1.EmissionTrigger_EMISSION_TRIGGER_CREATE
+ EmissionTrigger_EMISSION_TRIGGER_DELETE = job_specv1.EmissionTrigger_EMISSION_TRIGGER_DELETE
+)
+
+var (
+ EmissionTrigger_name = job_specv1.EmissionTrigger_name
+ EmissionTrigger_value = job_specv1.EmissionTrigger_value
+ File_job_spec_proto = job_specv1.File_job_spec_v1_job_spec_event_proto
+)
+
+type JobSpecEvent = job_specv1.JobSpecEvent
+type OCR1OracleSpecInfo = job_specv1.OCR1OracleSpecInfo
+type OCR2EVMRelayConfig = job_specv1.OCR2EVMRelayConfig
+type OCR2MedianPluginConfig = job_specv1.OCR2MedianPluginConfig
+type OCR2OracleSpecInfo = job_specv1.OCR2OracleSpecInfo
diff --git a/core/services/nodestatusreporter/jobspec/events/job_spec.proto b/core/services/nodestatusreporter/jobspec/events/job_spec.proto
new file mode 100644
index 00000000000..ad09b38d192
--- /dev/null
+++ b/core/services/nodestatusreporter/jobspec/events/job_spec.proto
@@ -0,0 +1,11 @@
+syntax = "proto3";
+
+package job_spec.v1;
+
+import public "job_spec/v1/job_spec_event.proto";
+import public "job_spec/v1/ocr1_oracle_spec_info.proto";
+import public "job_spec/v1/ocr2_evm_relay_config.proto";
+import public "job_spec/v1/ocr2_median_plugin_config.proto";
+import public "job_spec/v1/ocr2_oracle_spec_info.proto";
+
+option go_package = "github.com/smartcontractkit/chainlink/v2/core/services/nodestatusreporter/jobspec/events";
diff --git a/core/services/nodestatusreporter/jobspec/events/types.go b/core/services/nodestatusreporter/jobspec/events/types.go
new file mode 100644
index 00000000000..68f012a9b05
--- /dev/null
+++ b/core/services/nodestatusreporter/jobspec/events/types.go
@@ -0,0 +1,9 @@
+package events
+
+const (
+ ProtoPkg = "job_spec.v1"
+ JobSpecEventEntity = "JobSpecEvent"
+ SchemaJobSpec = "/job-spec-events/v1"
+ EventSource = "chainlink-protos"
+ BeholderDomain = "data-feeds.job-spec"
+)
diff --git a/core/services/nodestatusreporter/jobspec/job_spec_reporter.go b/core/services/nodestatusreporter/jobspec/job_spec_reporter.go
new file mode 100644
index 00000000000..eae5c446a68
--- /dev/null
+++ b/core/services/nodestatusreporter/jobspec/job_spec_reporter.go
@@ -0,0 +1,431 @@
+package jobspec
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/google/uuid"
+ "google.golang.org/protobuf/proto"
+
+ "github.com/smartcontractkit/chainlink-common/pkg/beholder"
+ "github.com/smartcontractkit/chainlink-common/pkg/services"
+ commontypes "github.com/smartcontractkit/chainlink-common/pkg/types"
+
+ coreconfig "github.com/smartcontractkit/chainlink/v2/core/config"
+ "github.com/smartcontractkit/chainlink/v2/core/logger"
+ "github.com/smartcontractkit/chainlink/v2/core/services/feeds"
+ "github.com/smartcontractkit/chainlink/v2/core/services/job"
+ "github.com/smartcontractkit/chainlink/v2/core/services/nodestatusreporter/jobspec/events"
+ medianconfig "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/median/config"
+ "github.com/smartcontractkit/chainlink/v2/core/services/pipeline"
+)
+
+const ServiceName = "JobSpecReporter"
+
+// Service polls active jobs and pushes their specs to Beholder, and also emits
+// on job create/delete via the job.Listener interface.
+type Service struct {
+ services.Service
+ eng *services.Engine
+
+ config coreconfig.JobSpecReporter
+ spawner job.Spawner
+ feedsORM feeds.ORM
+ emitter beholder.Emitter
+ csaPublicKey string
+ nodeVersion string
+ hostname string
+}
+
+func NewJobSpecReporter(
+ config coreconfig.JobSpecReporter,
+ spawner job.Spawner,
+ feedsORM feeds.ORM,
+ emitter beholder.Emitter,
+ csaPublicKey string,
+ nodeVersion string,
+ hostname string,
+ lggr logger.Logger,
+) *Service {
+ s := &Service{
+ config: config,
+ spawner: spawner,
+ feedsORM: feedsORM,
+ emitter: emitter,
+ csaPublicKey: csaPublicKey,
+ nodeVersion: nodeVersion,
+ hostname: hostname,
+ }
+ s.Service, s.eng = services.Config{
+ Name: ServiceName,
+ Start: s.start,
+ }.NewServiceEngine(lggr)
+ return s
+}
+
+func (s *Service) start(ctx context.Context) error {
+ if !s.config.Enabled() {
+ s.eng.Info("Job Spec Reporter Service is disabled")
+ return nil
+ }
+
+ s.eng.Info("Starting Job Spec Reporter Service")
+ s.spawner.RegisterListener(s)
+ ticker := services.NewTicker(s.config.PollingInterval())
+ s.eng.GoTick(ticker, s.pollAllJobs)
+
+ return nil
+}
+
+func (s *Service) HealthReport() map[string]error {
+ return map[string]error{ServiceName: s.Ready()}
+}
+
+// OnJobStarted emits a create event when a job starts.
+func (s *Service) OnJobStarted(ctx context.Context, jb job.Job) {
+ if !s.ShouldEmit(&jb) {
+ return
+ }
+ if err := s.EmitForJob(ctx, jb, events.EmissionTrigger_EMISSION_TRIGGER_CREATE); err != nil {
+ s.eng.Warnw("Failed to emit job spec telemetry on create", "jobID", jb.ID, "error", err)
+ }
+}
+
+// OnJobStopped emits a delete event when a job is removed.
+func (s *Service) OnJobStopped(ctx context.Context, jb job.Job) {
+ if !s.ShouldEmit(&jb) {
+ return
+ }
+ if err := s.EmitForJob(ctx, jb, events.EmissionTrigger_EMISSION_TRIGGER_DELETE); err != nil {
+ s.eng.Warnw("Failed to emit job spec telemetry on delete", "jobID", jb.ID, "error", err)
+ }
+}
+
+// pollAllJobs emits heartbeat telemetry for every active job that passes the emit gate.
+func (s *Service) pollAllJobs(ctx context.Context) {
+ for _, jb := range s.spawner.ActiveJobs() {
+ if !s.ShouldEmit(&jb) {
+ continue
+ }
+ if err := s.EmitForJob(ctx, jb, events.EmissionTrigger_EMISSION_TRIGGER_HEARTBEAT); err != nil {
+ s.eng.Warnw("Failed to emit job spec telemetry", "jobID", jb.ID, "error", err)
+ }
+ }
+}
+
+// ShouldEmit reports whether the job passes the config-driven emit gate.
+// Applied to heartbeat, create, and delete events alike.
+func (s *Service) ShouldEmit(j *job.Job) bool {
+ if j == nil {
+ return false
+ }
+ if j.Type != job.OffchainReporting2 || j.OCR2OracleSpec == nil {
+ return s.config.EmitNonOCR2Jobs()
+ }
+ allowed := s.config.EnabledOCR2PluginTypes()
+ if len(allowed) == 0 {
+ return true
+ }
+ pt := string(j.OCR2OracleSpec.PluginType)
+ for _, a := range allowed {
+ if a == pt {
+ return true
+ }
+ }
+ return false
+}
+
+// EmitForJob builds and emits a JobSpecEvent for the given job and trigger.
+func (s *Service) EmitForJob(ctx context.Context, jb job.Job, trigger events.EmissionTrigger) error {
+ event, err := s.buildEvent(ctx, jb, trigger)
+ if err != nil {
+ return fmt.Errorf("building event: %w", err)
+ }
+
+ if err := events.EmitJobSpecEvent(ctx, s.emitter, event); err != nil {
+ return fmt.Errorf("emitting event: %w", err)
+ }
+ return nil
+}
+
+// buildEvent converts a job.Job into its protobuf JobSpecEvent representation.
+func (s *Service) buildEvent(ctx context.Context, jb job.Job, trigger events.EmissionTrigger) (*events.JobSpecEvent, error) {
+ event := &events.JobSpecEvent{
+ ExternalJobId: jb.ExternalJobID.String(),
+ InternalJobId: jb.ID,
+ Name: jb.Name.ValueOrZero(),
+ JobType: string(jb.Type),
+ SchemaVersion: jb.SchemaVersion,
+ ForwardingAllowed: jb.ForwardingAllowed,
+ MaxTaskDurationSeconds: jb.MaxTaskDuration.Duration().Seconds(),
+ CreatedAt: jb.CreatedAt.Format(time.RFC3339Nano),
+ CsaPublicKey: s.csaPublicKey,
+ NodeVersion: s.nodeVersion,
+ Hostname: s.hostname,
+ EmissionTrigger: trigger,
+ Timestamp: time.Now().Format(time.RFC3339Nano),
+ }
+
+ if jb.GasLimit.Valid {
+ event.GasLimit = jb.GasLimit.Uint32
+ }
+ if jb.StreamID != nil {
+ sid := *jb.StreamID
+ event.StreamId = proto.Uint32(sid)
+ }
+
+ if jb.PipelineSpec != nil {
+ event.ObservationSource = jb.PipelineSpec.DotDagSource
+ event.PipelineSpecId = jb.PipelineSpec.ID
+ event.BridgeNames = extractBridgeNames(jb.Pipeline)
+ }
+
+ if err := s.populateProposalLifecycle(ctx, jb, event); err != nil {
+ s.eng.Warnw("Failed to populate proposal lifecycle", "jobID", jb.ID, "error", err)
+ }
+
+ if jb.Type == job.OffchainReporting2 && jb.OCR2OracleSpec != nil {
+ ocr2Info, err := buildOCR2OracleSpecInfo(jb.OCR2OracleSpec)
+ if err != nil {
+ return nil, fmt.Errorf("building OCR2OracleSpecInfo: %w", err)
+ }
+ event.Ocr2OracleSpec = ocr2Info
+ event.ContractAddress = jb.OCR2OracleSpec.ContractID
+ event.ChainId = jb.OCR2OracleSpec.ChainID
+ }
+
+ if jb.Type == job.OffchainReporting && jb.OCROracleSpec != nil {
+ event.ContractAddress = jb.OCROracleSpec.ContractAddress.String()
+ if jb.OCROracleSpec.EVMChainID != nil {
+ event.ChainId = jb.OCROracleSpec.EVMChainID.String()
+ }
+ event.Ocr1OracleSpec = buildOCR1OracleSpecInfo(jb.OCROracleSpec)
+ }
+
+ return event, nil
+}
+
+// populateProposalLifecycle fills in proposal/approval fields for jobs created
+// via the Feeds Manager. Jobs not managed by Feeds Manager are a no-op.
+func (s *Service) populateProposalLifecycle(ctx context.Context, jb job.Job, event *events.JobSpecEvent) error {
+ if s.feedsORM == nil || jb.ExternalJobID == uuid.Nil {
+ return nil
+ }
+
+ prop, err := s.feedsORM.GetJobProposalByExternalJobID(ctx, jb.ExternalJobID)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil
+ }
+ return fmt.Errorf("fetching job proposal: %w", err)
+ }
+
+ spec, err := s.feedsORM.GetApprovedSpec(ctx, prop.ID)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil
+ }
+ return fmt.Errorf("fetching approved spec: %w", err)
+ }
+
+ event.FeedsManagerId = prop.FeedsManagerID
+ event.RemoteUuid = prop.RemoteUUID.String()
+ event.SpecVersion = spec.Version
+ event.ProposedAt = spec.CreatedAt.Format(time.RFC3339Nano)
+ event.ApprovedAt = spec.StatusUpdatedAt.Format(time.RFC3339Nano)
+ event.AcceptLatencySeconds = spec.StatusUpdatedAt.Sub(spec.CreatedAt).Seconds()
+ return nil
+}
+
+// extractBridgeNames returns the names of bridge tasks in the top-level pipeline.
+// Tasks inside sub-pipelines (e.g. juelsPerFeeCoinSource) are not included.
+func extractBridgeNames(p pipeline.Pipeline) []string {
+ var names []string
+ for _, task := range p.Tasks {
+ if task.Type() != pipeline.TaskTypeBridge {
+ continue
+ }
+ bt, ok := task.(*pipeline.BridgeTask)
+ if !ok {
+ continue
+ }
+ names = append(names, bt.Name)
+ }
+ return names
+}
+
+// evmRelayConfig mirrors the EVM relay config JSON so we can surface its fields
+// in OCR2EVMRelayConfig without depending on the EVM module.
+type evmRelayConfig struct {
+ ChainID string `json:"chainID"`
+ FromBlock uint64 `json:"fromBlock"`
+ EffectiveTransmitterID string `json:"effectiveTransmitterID"`
+ EnableDualTransmission bool `json:"enableDualTransmission"`
+ EnableTriggerCapability bool `json:"enableTriggerCapability"`
+ LLODonID uint64 `json:"lloDonID"`
+ FeedID string `json:"feedID"`
+ SendingKeys []string `json:"sendingKeys"`
+ ProviderType string `json:"providerType"`
+}
+
+// buildOCR2OracleSpecInfo converts an OCR2OracleSpec into the proto message.
+func buildOCR2OracleSpecInfo(spec *job.OCR2OracleSpec) (*events.OCR2OracleSpecInfo, error) {
+ relayConfigRaw, err := json.Marshal(spec.RelayConfig)
+ if err != nil {
+ return nil, fmt.Errorf("marshaling relay config: %w", err)
+ }
+ pluginConfigRaw, err := json.Marshal(spec.PluginConfig)
+ if err != nil {
+ return nil, fmt.Errorf("marshaling plugin config: %w", err)
+ }
+ onchainStrategyRaw, err := json.Marshal(spec.OnchainSigningStrategy)
+ if err != nil {
+ return nil, fmt.Errorf("marshaling onchain signing strategy: %w", err)
+ }
+
+ feedID := ""
+ if spec.FeedID != nil {
+ feedID = spec.FeedID.Hex()
+ }
+
+ info := &events.OCR2OracleSpecInfo{
+ SpecId: spec.ID,
+ FeedId: feedID,
+ Relay: spec.Relay,
+ PluginType: string(spec.PluginType),
+ TransmitterId: spec.TransmitterID.ValueOrZero(),
+ OcrKeyBundleId: spec.OCRKeyBundleID.ValueOrZero(),
+ MonitoringEndpoint: spec.MonitoringEndpoint.ValueOrZero(),
+ P2Pv2Bootstrappers: spec.P2PV2Bootstrappers,
+ AllowNoBootstrappers: spec.AllowNoBootstrappers,
+ BlockchainTimeoutSeconds: spec.BlockchainTimeout.Duration().Seconds(),
+ ContractConfigTrackerPollIntervalSeconds: spec.ContractConfigTrackerPollInterval.Duration().Seconds(),
+ ContractConfigConfirmations: uint32(spec.ContractConfigConfirmations),
+ CaptureEaTelemetry: spec.CaptureEATelemetry,
+ CaptureAutomationCustomTelemetry: spec.CaptureAutomationCustomTelemetry,
+ SpecCreatedAt: spec.CreatedAt.Format(time.RFC3339Nano),
+ SpecUpdatedAt: spec.UpdatedAt.Format(time.RFC3339Nano),
+ RelayConfigJson: string(relayConfigRaw),
+ PluginConfigJson: string(pluginConfigRaw),
+ OnchainSigningStrategyJson: string(onchainStrategyRaw),
+ }
+
+ if spec.Relay == "evm" {
+ evmCfg, err := buildEVMRelayConfig(relayConfigRaw)
+ if err != nil {
+ return nil, fmt.Errorf("building EVM relay config: %w", err)
+ }
+ info.EvmRelayConfig = evmCfg
+ }
+
+ if spec.PluginType == commontypes.Median {
+ medianCfg, err := buildMedianPluginConfig(pluginConfigRaw)
+ if err != nil {
+ return nil, fmt.Errorf("building median plugin config: %w", err)
+ }
+ info.MedianPluginConfig = medianCfg
+ }
+
+ return info, nil
+}
+
+func buildOCR1OracleSpecInfo(spec *job.OCROracleSpec) *events.OCR1OracleSpecInfo {
+ keyBundleID := ""
+ if spec.EncryptedOCRKeyBundleID != nil {
+ keyBundleID = spec.EncryptedOCRKeyBundleID.String()
+ }
+
+ transmitterAddress := ""
+ if spec.TransmitterAddress != nil {
+ transmitterAddress = spec.TransmitterAddress.String()
+ }
+
+ var dbTimeoutSeconds float64
+ if spec.DatabaseTimeout != nil {
+ dbTimeoutSeconds = spec.DatabaseTimeout.Duration().Seconds()
+ }
+
+ var gracePeriodSeconds float64
+ if spec.ObservationGracePeriod != nil {
+ gracePeriodSeconds = spec.ObservationGracePeriod.Duration().Seconds()
+ }
+
+ var transmitTimeoutSeconds float64
+ if spec.ContractTransmitterTransmitTimeout != nil {
+ transmitTimeoutSeconds = spec.ContractTransmitterTransmitTimeout.Duration().Seconds()
+ }
+
+ return &events.OCR1OracleSpecInfo{
+ SpecId: spec.ID,
+ P2Pv2Bootstrappers: spec.P2PV2Bootstrappers,
+ IsBootstrapPeer: spec.IsBootstrapPeer,
+ OcrKeyBundleId: keyBundleID,
+ TransmitterAddress: transmitterAddress,
+ ObservationTimeoutSeconds: spec.ObservationTimeout.Duration().Seconds(),
+ BlockchainTimeoutSeconds: spec.BlockchainTimeout.Duration().Seconds(),
+ ContractConfigTrackerSubscribeIntervalSeconds: spec.ContractConfigTrackerSubscribeInterval.Duration().Seconds(),
+ ContractConfigTrackerPollIntervalSeconds: spec.ContractConfigTrackerPollInterval.Duration().Seconds(),
+ ContractConfigConfirmations: uint32(spec.ContractConfigConfirmations),
+ DatabaseTimeoutSeconds: dbTimeoutSeconds,
+ ObservationGracePeriodSeconds: gracePeriodSeconds,
+ ContractTransmitterTransmitTimeoutSeconds: transmitTimeoutSeconds,
+ CaptureEaTelemetry: spec.CaptureEATelemetry,
+ SpecCreatedAt: spec.CreatedAt.Format(time.RFC3339Nano),
+ SpecUpdatedAt: spec.UpdatedAt.Format(time.RFC3339Nano),
+ }
+}
+
+// buildEVMRelayConfig decodes the EVM relay config JSON into OCR2EVMRelayConfig.
+func buildEVMRelayConfig(relayConfigJSON []byte) (*events.OCR2EVMRelayConfig, error) {
+ var cfg evmRelayConfig
+ if err := json.Unmarshal(relayConfigJSON, &cfg); err != nil {
+ return nil, fmt.Errorf("unmarshaling EVM relay config: %w", err)
+ }
+
+ return &events.OCR2EVMRelayConfig{
+ ChainId: cfg.ChainID,
+ FromBlock: cfg.FromBlock,
+ EffectiveTransmitterId: cfg.EffectiveTransmitterID,
+ EnableDualTransmission: cfg.EnableDualTransmission,
+ EnableTriggerCapability: cfg.EnableTriggerCapability,
+ LloDonId: cfg.LLODonID,
+ FeedId: cfg.FeedID,
+ SendingKeys: cfg.SendingKeys,
+ ProviderType: cfg.ProviderType,
+ }, nil
+}
+
+// buildMedianPluginConfig decodes the median plugin config JSON into OCR2MedianPluginConfig.
+func buildMedianPluginConfig(pluginConfigJSON []byte) (*events.OCR2MedianPluginConfig, error) {
+ var cfg medianconfig.PluginConfig
+ if err := json.Unmarshal(pluginConfigJSON, &cfg); err != nil {
+ return nil, fmt.Errorf("unmarshaling median plugin config: %w", err)
+ }
+
+ medianProto := &events.OCR2MedianPluginConfig{
+ JuelsPerFeeCoinSource: cfg.JuelsPerFeeCoinPipeline,
+ GasPriceSubunitsSource: cfg.GasPriceSubunitsPipeline,
+ }
+
+ if cfg.JuelsPerFeeCoinCache == nil {
+ medianProto.JuelsPerFeeCoinCacheDisabled = true
+ } else {
+ medianProto.JuelsPerFeeCoinCacheDisabled = cfg.JuelsPerFeeCoinCache.Disable
+ medianProto.JuelsPerFeeCoinCacheUpdateIntervalSeconds = cfg.JuelsPerFeeCoinCache.UpdateInterval.Duration().Seconds()
+ medianProto.JuelsPerFeeCoinCacheStalenessAlertThresholdSeconds = cfg.JuelsPerFeeCoinCache.StalenessAlertThreshold.Duration().Seconds()
+ }
+
+ if cfg.DeviationFunctionDefinition != nil {
+ devFuncRaw, err := json.Marshal(cfg.DeviationFunctionDefinition)
+ if err != nil {
+ return nil, fmt.Errorf("marshaling deviation function definition: %w", err)
+ }
+ medianProto.DeviationFuncJson = string(devFuncRaw)
+ }
+
+ return medianProto, nil
+}
diff --git a/core/services/nodestatusreporter/jobspec/job_spec_reporter_test.go b/core/services/nodestatusreporter/jobspec/job_spec_reporter_test.go
new file mode 100644
index 00000000000..d1f9d36bcec
--- /dev/null
+++ b/core/services/nodestatusreporter/jobspec/job_spec_reporter_test.go
@@ -0,0 +1,447 @@
+package jobspec_test
+
+import (
+ "context"
+ "database/sql"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/lib/pq"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/proto"
+ "gopkg.in/guregu/null.v4"
+
+ "github.com/smartcontractkit/chainlink-common/keystore/corekeys"
+ "github.com/smartcontractkit/chainlink-common/pkg/beholder"
+ "github.com/smartcontractkit/chainlink-common/pkg/beholder/beholdertest"
+ "github.com/smartcontractkit/chainlink-common/pkg/sqlutil"
+ commontypes "github.com/smartcontractkit/chainlink-common/pkg/types"
+
+ evmtypes "github.com/smartcontractkit/chainlink-evm/pkg/types"
+
+ "github.com/smartcontractkit/chainlink/v2/core/logger"
+ "github.com/smartcontractkit/chainlink/v2/core/services/feeds"
+ feedsmocks "github.com/smartcontractkit/chainlink/v2/core/services/feeds/mocks"
+ "github.com/smartcontractkit/chainlink/v2/core/services/job"
+ jobmocks "github.com/smartcontractkit/chainlink/v2/core/services/job/mocks"
+ "github.com/smartcontractkit/chainlink/v2/core/services/nodestatusreporter/jobspec"
+ "github.com/smartcontractkit/chainlink/v2/core/services/nodestatusreporter/jobspec/events"
+ "github.com/smartcontractkit/chainlink/v2/core/services/pipeline"
+)
+
+// stubConfig implements config.JobSpecReporter for tests.
+type stubConfig struct {
+ enabled bool
+ pollingInterval time.Duration
+ enabledOCR2PluginTypes []string
+ emitNonOCR2Jobs bool
+}
+
+func (s *stubConfig) Enabled() bool { return s.enabled }
+func (s *stubConfig) PollingInterval() time.Duration { return s.pollingInterval }
+func (s *stubConfig) EnabledOCR2PluginTypes() []string { return s.enabledOCR2PluginTypes }
+func (s *stubConfig) EmitNonOCR2Jobs() bool { return s.emitNonOCR2Jobs }
+
+func defaultConfig() *stubConfig {
+ return &stubConfig{
+ enabled: true,
+ pollingInterval: time.Hour,
+ enabledOCR2PluginTypes: []string{"median"},
+ emitNonOCR2Jobs: false,
+ }
+}
+
+func makeMedianJob() job.Job {
+ return job.Job{
+ ID: 1,
+ ExternalJobID: uuid.New(),
+ Name: null.StringFrom("test-median-job"),
+ Type: job.OffchainReporting2,
+ SchemaVersion: 1,
+ PipelineSpec: &pipeline.Spec{
+ ID: 10,
+ DotDagSource: `ds1 [type=bridge name="my-bridge"]`,
+ },
+ Pipeline: pipeline.Pipeline{
+ Tasks: []pipeline.Task{
+ &pipeline.BridgeTask{
+ BaseTask: pipeline.NewBaseTask(0, "ds1", nil, nil, 0),
+ Name: "my-bridge",
+ },
+ },
+ },
+ OCR2OracleSpec: &job.OCR2OracleSpec{
+ ID: 1,
+ ContractID: "0x1234567890abcdef",
+ Relay: "evm",
+ ChainID: "1",
+ PluginType: commontypes.Median,
+ RelayConfig: job.JSONConfig{"chainID": "1"},
+ PluginConfig: job.JSONConfig{"juelsPerFeeCoinSource": `ds1 [type=http method=GET url="https://example.com"]`},
+ OnchainSigningStrategy: job.JSONConfig{},
+ P2PV2Bootstrappers: []string{"12D3KooW@host:6688"},
+ ContractConfigConfirmations: 1,
+ },
+ CreatedAt: time.Now(),
+ }
+}
+
+func makeNonMedianOCR2Job() job.Job {
+ jb := makeMedianJob()
+ jb.ID = 2
+ jb.ExternalJobID = uuid.New()
+ jb.Name = null.StringFrom("test-keeper-job")
+ jb.OCR2OracleSpec = &job.OCR2OracleSpec{
+ ID: 2,
+ ContractID: "0xabcdef1234567890",
+ Relay: "evm",
+ ChainID: "1",
+ PluginType: commontypes.OCR2PluginType("ocr2keeper"),
+ RelayConfig: job.JSONConfig{"chainID": "1"},
+ PluginConfig: job.JSONConfig{},
+ OnchainSigningStrategy: job.JSONConfig{},
+ }
+ return jb
+}
+
+func makeVRFJob() job.Job {
+ return job.Job{
+ ID: 3,
+ ExternalJobID: uuid.New(),
+ Name: null.StringFrom("test-vrf-job"),
+ Type: job.VRF,
+ SchemaVersion: 1,
+ PipelineSpec: &pipeline.Spec{ID: 30, DotDagSource: ""},
+ Pipeline: pipeline.Pipeline{},
+ CreatedAt: time.Now(),
+ }
+}
+
+// newTestReporter returns a Service wired to the current global beholder emitter.
+// The caller must set up the test emitter via beholdertest.NewObserver(t) first.
+func newTestReporter(t *testing.T, cfg *stubConfig, feedsORM feeds.ORM) *jobspec.Service {
+ t.Helper()
+ spawner := jobmocks.NewSpawner(t)
+ return jobspec.NewJobSpecReporter(cfg, spawner, feedsORM, beholder.GetEmitter(), "csa-key", "1.0.0", "test-host", logger.TestLogger(t))
+}
+
+// newFeedsORMWithoutProposal returns a feeds ORM mock that behaves as if the
+// given job was created outside of the Feeds Manager.
+func newFeedsORMWithoutProposal(t *testing.T, jb job.Job) *feedsmocks.ORM {
+ t.Helper()
+ feedsORM := feedsmocks.NewORM(t)
+ feedsORM.On("GetJobProposalByExternalJobID", mock.Anything, jb.ExternalJobID).Return(nil, sql.ErrNoRows).Maybe()
+ return feedsORM
+}
+
+func TestShouldEmit_DefaultConfig(t *testing.T) {
+ beholdertest.NewObserver(t)
+ svc := newTestReporter(t, defaultConfig(), nil)
+
+ median := makeMedianJob()
+ nonMedian := makeNonMedianOCR2Job()
+ vrf := makeVRFJob()
+
+ cases := []struct {
+ name string
+ jb *job.Job
+ want bool
+ }{
+ {"median OCR2 job emits", &median, true},
+ {"non-median OCR2 job skipped", &nonMedian, false},
+ {"non-OCR2 (VRF) job skipped", &vrf, false},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ assert.Equal(t, tc.want, svc.ShouldEmit(tc.jb))
+ })
+ }
+}
+
+func TestShouldEmit_AllOCR2Types(t *testing.T) {
+ beholdertest.NewObserver(t)
+ cfg := defaultConfig()
+ cfg.enabledOCR2PluginTypes = []string{} // empty allowlist = all OCR2 types
+
+ svc := newTestReporter(t, cfg, nil)
+
+ median := makeMedianJob()
+ nonMedian := makeNonMedianOCR2Job()
+ vrf := makeVRFJob()
+
+ assert.True(t, svc.ShouldEmit(&median))
+ assert.True(t, svc.ShouldEmit(&nonMedian))
+ assert.False(t, svc.ShouldEmit(&vrf))
+}
+
+func TestShouldEmit_NonOCR2Enabled(t *testing.T) {
+ beholdertest.NewObserver(t)
+ cfg := defaultConfig()
+ cfg.emitNonOCR2Jobs = true
+
+ svc := newTestReporter(t, cfg, nil)
+
+ median := makeMedianJob()
+ vrf := makeVRFJob()
+
+ assert.True(t, svc.ShouldEmit(&vrf))
+ assert.True(t, svc.ShouldEmit(&median))
+}
+
+func TestBuildEvent_MedianJob(t *testing.T) {
+ observer := beholdertest.NewObserver(t)
+
+ jb := makeMedianJob()
+ svc := newTestReporter(t, defaultConfig(), newFeedsORMWithoutProposal(t, jb))
+
+ err := svc.EmitForJob(context.Background(), jb, events.EmissionTrigger_EMISSION_TRIGGER_HEARTBEAT)
+ require.NoError(t, err)
+
+ msgs := observer.Messages(t, "beholder_entity", events.ProtoPkg+"."+events.JobSpecEventEntity)
+ require.Len(t, msgs, 1)
+
+ var ev events.JobSpecEvent
+ require.NoError(t, proto.Unmarshal(msgs[0].Body, &ev))
+
+ assert.Equal(t, jb.ExternalJobID.String(), ev.ExternalJobId)
+ assert.Equal(t, jb.ID, ev.InternalJobId)
+ assert.Equal(t, "test-median-job", ev.Name)
+ assert.Equal(t, "offchainreporting2", ev.JobType)
+ assert.Equal(t, events.EmissionTrigger_EMISSION_TRIGGER_HEARTBEAT, ev.EmissionTrigger)
+ assert.Equal(t, "csa-key", ev.CsaPublicKey)
+ assert.Equal(t, "1.0.0", ev.NodeVersion)
+ assert.Equal(t, "test-host", ev.Hostname)
+ assert.Equal(t, []string{"my-bridge"}, ev.BridgeNames)
+ assert.Equal(t, "0x1234567890abcdef", ev.ContractAddress)
+ assert.Equal(t, "1", ev.ChainId)
+ require.NotNil(t, ev.Ocr2OracleSpec)
+ assert.Equal(t, "evm", ev.Ocr2OracleSpec.Relay)
+ assert.Equal(t, "median", ev.Ocr2OracleSpec.PluginType)
+ require.NotNil(t, ev.Ocr2OracleSpec.MedianPluginConfig)
+ assert.NotEmpty(t, ev.Ocr2OracleSpec.MedianPluginConfig.JuelsPerFeeCoinSource)
+ require.NotNil(t, ev.Ocr2OracleSpec.EvmRelayConfig)
+ assert.Equal(t, "1", ev.Ocr2OracleSpec.EvmRelayConfig.ChainId)
+ assert.Nil(t, ev.Ocr1OracleSpec)
+}
+
+func TestBuildEvent_NonMedianOCR2Job(t *testing.T) {
+ observer := beholdertest.NewObserver(t)
+
+ jb := makeNonMedianOCR2Job()
+ svc := newTestReporter(t, defaultConfig(), newFeedsORMWithoutProposal(t, jb))
+
+ err := svc.EmitForJob(context.Background(), jb, events.EmissionTrigger_EMISSION_TRIGGER_CREATE)
+ require.NoError(t, err)
+
+ msgs := observer.Messages(t, "beholder_entity", events.ProtoPkg+"."+events.JobSpecEventEntity)
+ require.Len(t, msgs, 1)
+
+ var ev events.JobSpecEvent
+ require.NoError(t, proto.Unmarshal(msgs[0].Body, &ev))
+
+ require.NotNil(t, ev.Ocr2OracleSpec)
+ assert.Equal(t, "ocr2keeper", ev.Ocr2OracleSpec.PluginType)
+ assert.Nil(t, ev.Ocr2OracleSpec.MedianPluginConfig)
+ assert.NotEmpty(t, ev.Ocr2OracleSpec.RelayConfigJson)
+}
+
+func TestBuildEvent_NonOCR2Job(t *testing.T) {
+ observer := beholdertest.NewObserver(t)
+
+ svc := newTestReporter(t, defaultConfig(), nil)
+
+ jb := makeVRFJob()
+ err := svc.EmitForJob(context.Background(), jb, events.EmissionTrigger_EMISSION_TRIGGER_HEARTBEAT)
+ require.NoError(t, err)
+
+ msgs := observer.Messages(t, "beholder_entity", events.ProtoPkg+"."+events.JobSpecEventEntity)
+ require.Len(t, msgs, 1)
+
+ var ev events.JobSpecEvent
+ require.NoError(t, proto.Unmarshal(msgs[0].Body, &ev))
+
+ assert.Equal(t, "vrf", ev.JobType)
+ assert.Nil(t, ev.Ocr2OracleSpec)
+}
+
+func TestOnJobStarted_EmitsCreate(t *testing.T) {
+ observer := beholdertest.NewObserver(t)
+
+ jb := makeMedianJob()
+ svc := newTestReporter(t, defaultConfig(), newFeedsORMWithoutProposal(t, jb))
+ svc.OnJobStarted(context.Background(), jb)
+
+ msgs := observer.Messages(t, "beholder_entity", events.ProtoPkg+"."+events.JobSpecEventEntity)
+ require.Len(t, msgs, 1)
+
+ var ev events.JobSpecEvent
+ require.NoError(t, proto.Unmarshal(msgs[0].Body, &ev))
+ assert.Equal(t, events.EmissionTrigger_EMISSION_TRIGGER_CREATE, ev.EmissionTrigger)
+}
+
+func TestOnJobStopped_EmitsDelete(t *testing.T) {
+ observer := beholdertest.NewObserver(t)
+
+ jb := makeMedianJob()
+ svc := newTestReporter(t, defaultConfig(), newFeedsORMWithoutProposal(t, jb))
+ svc.OnJobStopped(context.Background(), jb)
+
+ msgs := observer.Messages(t, "beholder_entity", events.ProtoPkg+"."+events.JobSpecEventEntity)
+ require.Len(t, msgs, 1)
+
+ var ev events.JobSpecEvent
+ require.NoError(t, proto.Unmarshal(msgs[0].Body, &ev))
+ assert.Equal(t, events.EmissionTrigger_EMISSION_TRIGGER_DELETE, ev.EmissionTrigger)
+}
+
+func TestOnJobStarted_SkippedWhenGateFails(t *testing.T) {
+ observer := beholdertest.NewObserver(t)
+
+ // default config only allows median, so a VRF job should be skipped
+ svc := newTestReporter(t, defaultConfig(), nil)
+ svc.OnJobStarted(context.Background(), makeVRFJob())
+
+ msgs := observer.Messages(t, "beholder_entity", events.ProtoPkg+"."+events.JobSpecEventEntity)
+ require.Empty(t, msgs)
+}
+
+func TestBuildEvent_ProposalLifecycle(t *testing.T) {
+ observer := beholdertest.NewObserver(t)
+ feedsORM := feedsmocks.NewORM(t)
+
+ jb := makeMedianJob()
+ proposedAt := time.Now().Add(-5 * time.Minute)
+ approvedAt := time.Now().Add(-2 * time.Minute)
+
+ prop := &feeds.JobProposal{
+ ID: 100,
+ FeedsManagerID: 7,
+ RemoteUUID: uuid.New(),
+ }
+ spec := &feeds.JobProposalSpec{
+ ID: 200,
+ Version: 3,
+ CreatedAt: proposedAt,
+ StatusUpdatedAt: approvedAt,
+ }
+
+ feedsORM.On("GetJobProposalByExternalJobID", mock.Anything, jb.ExternalJobID).Return(prop, nil)
+ feedsORM.On("GetApprovedSpec", mock.Anything, prop.ID).Return(spec, nil)
+
+ svc := newTestReporter(t, defaultConfig(), feedsORM)
+ err := svc.EmitForJob(context.Background(), jb, events.EmissionTrigger_EMISSION_TRIGGER_HEARTBEAT)
+ require.NoError(t, err)
+
+ msgs := observer.Messages(t, "beholder_entity", events.ProtoPkg+"."+events.JobSpecEventEntity)
+ require.Len(t, msgs, 1)
+
+ var ev events.JobSpecEvent
+ require.NoError(t, proto.Unmarshal(msgs[0].Body, &ev))
+
+ assert.Equal(t, int64(7), ev.FeedsManagerId)
+ assert.Equal(t, prop.RemoteUUID.String(), ev.RemoteUuid)
+ assert.Equal(t, int32(3), ev.SpecVersion)
+ assert.InDelta(t, approvedAt.Sub(proposedAt).Seconds(), ev.AcceptLatencySeconds, 1.0)
+}
+
+func TestBuildEvent_ContractFields_OCR1(t *testing.T) {
+ observer := beholdertest.NewObserver(t)
+ cfg := &stubConfig{
+ enabled: true,
+ pollingInterval: time.Hour,
+ emitNonOCR2Jobs: true,
+ }
+ svc := newTestReporter(t, cfg, nil)
+
+ jb := makeOCR1Job()
+ err := svc.EmitForJob(context.Background(), jb, events.EmissionTrigger_EMISSION_TRIGGER_HEARTBEAT)
+ require.NoError(t, err)
+
+ msgs := observer.Messages(t, "beholder_entity", events.ProtoPkg+"."+events.JobSpecEventEntity)
+ require.Len(t, msgs, 1)
+
+ var ev events.JobSpecEvent
+ require.NoError(t, proto.Unmarshal(msgs[0].Body, &ev))
+
+ // top-level contract identity
+ assert.Equal(t, "0x9d9305445F404E925563d5D5EcC65C815Ec1655b", ev.ContractAddress)
+ assert.Equal(t, "11155111", ev.ChainId)
+ assert.Equal(t, "offchainreporting", ev.JobType)
+
+ // OCR1 sub-message
+ require.NotNil(t, ev.Ocr1OracleSpec)
+ ocr1 := ev.Ocr1OracleSpec
+ assert.Equal(t, int32(99), ocr1.SpecId)
+ assert.Equal(t, []string{"12D3KooW@bootstrap:6688"}, ocr1.P2Pv2Bootstrappers)
+ assert.False(t, ocr1.IsBootstrapPeer)
+ assert.Equal(t, "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", ocr1.OcrKeyBundleId)
+ assert.Equal(t, "0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B", ocr1.TransmitterAddress)
+ assert.InDelta(t, 30.0, ocr1.ObservationTimeoutSeconds, 0.001)
+ assert.InDelta(t, 20.0, ocr1.BlockchainTimeoutSeconds, 0.001)
+ assert.InDelta(t, 120.0, ocr1.ContractConfigTrackerSubscribeIntervalSeconds, 0.001)
+ assert.InDelta(t, 60.0, ocr1.ContractConfigTrackerPollIntervalSeconds, 0.001)
+ assert.Equal(t, uint32(3), ocr1.ContractConfigConfirmations)
+ assert.InDelta(t, 10.0, ocr1.DatabaseTimeoutSeconds, 0.001)
+ assert.InDelta(t, 1.0, ocr1.ObservationGracePeriodSeconds, 0.001)
+ assert.InDelta(t, 5.0, ocr1.ContractTransmitterTransmitTimeoutSeconds, 0.001)
+ assert.True(t, ocr1.CaptureEaTelemetry)
+ assert.Equal(t, "2026-01-01T00:00:00Z", ocr1.SpecCreatedAt)
+ assert.Equal(t, "2026-02-01T00:00:00Z", ocr1.SpecUpdatedAt)
+
+ // OCR2 sub-message absent for OCR1 jobs
+ assert.Nil(t, ev.Ocr2OracleSpec)
+}
+
+func makeOCR1Job() job.Job {
+ keyHash, err := corekeys.Sha256HashFromHex("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20")
+ if err != nil {
+ panic(err)
+ }
+ transmitter := evmtypes.MustEIP55Address("0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B")
+ specCreatedAt := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ specUpdatedAt := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
+ dbTimeout := sqlutil.Interval(10 * time.Second)
+ gracePeriod := sqlutil.Interval(1 * time.Second)
+ transmitTimeout := sqlutil.Interval(5 * time.Second)
+ return job.Job{
+ ID: 4,
+ ExternalJobID: uuid.New(),
+ Name: null.StringFrom("test-ocr1-job"),
+ Type: job.OffchainReporting,
+ SchemaVersion: 1,
+ PipelineSpec: &pipeline.Spec{ID: 40, DotDagSource: `ds1 [type=bridge name="bridge-gsr"]`},
+ Pipeline: pipeline.Pipeline{
+ Tasks: []pipeline.Task{
+ &pipeline.BridgeTask{
+ BaseTask: pipeline.NewBaseTask(0, "ds1", nil, nil, 0),
+ Name: "bridge-gsr",
+ },
+ },
+ },
+ OCROracleSpec: &job.OCROracleSpec{
+ ID: 99,
+ ContractAddress: evmtypes.MustEIP55Address("0x9d9305445F404E925563d5D5EcC65C815Ec1655b"),
+ EVMChainID: sqlutil.NewI(11155111),
+ P2PV2Bootstrappers: pq.StringArray{"12D3KooW@bootstrap:6688"},
+ IsBootstrapPeer: false,
+ EncryptedOCRKeyBundleID: &keyHash,
+ TransmitterAddress: &transmitter,
+ ObservationTimeout: sqlutil.Interval(30 * time.Second),
+ BlockchainTimeout: sqlutil.Interval(20 * time.Second),
+ ContractConfigTrackerSubscribeInterval: sqlutil.Interval(2 * time.Minute),
+ ContractConfigTrackerPollInterval: sqlutil.Interval(1 * time.Minute),
+ ContractConfigConfirmations: 3,
+ DatabaseTimeout: &dbTimeout,
+ ObservationGracePeriod: &gracePeriod,
+ ContractTransmitterTransmitTimeout: &transmitTimeout,
+ CaptureEATelemetry: true,
+ CreatedAt: specCreatedAt,
+ UpdatedAt: specUpdatedAt,
+ },
+ CreatedAt: time.Now(),
+ }
+}
diff --git a/core/web/resolver/testdata/config-empty-effective.toml b/core/web/resolver/testdata/config-empty-effective.toml
index bd00ab34ccb..53f70792803 100644
--- a/core/web/resolver/testdata/config-empty-effective.toml
+++ b/core/web/resolver/testdata/config-empty-effective.toml
@@ -399,6 +399,12 @@ PollingInterval = '5m0s'
IgnoreInvalidBridges = true
IgnoreJoblessBridges = false
+[JobSpecReporter]
+Enabled = false
+PollingInterval = '1h0m0s'
+EnabledOCR2PluginTypes = ['median']
+EmitNonOCR2Jobs = false
+
[Sharding]
ShardingEnabled = false
ArbiterPort = 9876
diff --git a/core/web/resolver/testdata/config-full.toml b/core/web/resolver/testdata/config-full.toml
index 5543f86326f..68001b07d6b 100644
--- a/core/web/resolver/testdata/config-full.toml
+++ b/core/web/resolver/testdata/config-full.toml
@@ -416,6 +416,12 @@ PollingInterval = '5m0s'
IgnoreInvalidBridges = true
IgnoreJoblessBridges = false
+[JobSpecReporter]
+Enabled = false
+PollingInterval = '1h0m0s'
+EnabledOCR2PluginTypes = ['median']
+EmitNonOCR2Jobs = false
+
[Sharding]
ShardingEnabled = false
ArbiterPort = 9876
diff --git a/core/web/resolver/testdata/config-multi-chain-effective.toml b/core/web/resolver/testdata/config-multi-chain-effective.toml
index 6d4542a1092..584664fd02f 100644
--- a/core/web/resolver/testdata/config-multi-chain-effective.toml
+++ b/core/web/resolver/testdata/config-multi-chain-effective.toml
@@ -399,6 +399,12 @@ PollingInterval = '5m0s'
IgnoreInvalidBridges = true
IgnoreJoblessBridges = false
+[JobSpecReporter]
+Enabled = false
+PollingInterval = '1h0m0s'
+EnabledOCR2PluginTypes = ['median']
+EmitNonOCR2Jobs = false
+
[Sharding]
ShardingEnabled = false
ArbiterPort = 9876
diff --git a/core/web/testdata/body/health.html b/core/web/testdata/body/health.html
index cf189621cf5..1b9a4ca70d1 100644
--- a/core/web/testdata/body/health.html
+++ b/core/web/testdata/body/health.html
@@ -93,6 +93,9 @@
JobSpawner
+
+ JobSpecReporter
+
LLOTransmissionReaper
diff --git a/core/web/testdata/body/health.json b/core/web/testdata/body/health.json
index 31054ef17e7..8a2138abe59 100644
--- a/core/web/testdata/body/health.json
+++ b/core/web/testdata/body/health.json
@@ -171,6 +171,15 @@
"output": ""
}
},
+ {
+ "type": "checks",
+ "id": "JobSpecReporter",
+ "attributes": {
+ "name": "JobSpecReporter",
+ "status": "passing",
+ "output": ""
+ }
+ },
{
"type": "checks",
"id": "LLOTransmissionReaper",
diff --git a/core/web/testdata/body/health.txt b/core/web/testdata/body/health.txt
index 59a77f4a057..dab28b06375 100644
--- a/core/web/testdata/body/health.txt
+++ b/core/web/testdata/body/health.txt
@@ -18,6 +18,7 @@ ok EVM.1399100.Txm.WrappedEvmEstimator
ok HeadReporter
ok Heartbeat
ok JobSpawner
+ok JobSpecReporter
ok LLOTransmissionReaper
ok Mailbox.Monitor
ok Mercury.WSRPCPool
diff --git a/deployment/go.mod b/deployment/go.mod
index afe257dad5c..737451bc523 100644
--- a/deployment/go.mod
+++ b/deployment/go.mod
@@ -436,6 +436,7 @@ require (
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/heartbeat v0.0.0-20260115142640-f6b99095c12e // indirect
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/message-discovery v0.0.0-20251211142334-5c3421fe2c8d // indirect
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d // indirect
+ github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a // indirect
github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b // indirect
github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260319180422-b5808c964785 // indirect
github.com/smartcontractkit/chainlink-protos/op-catalog v0.0.4 // indirect
diff --git a/deployment/go.sum b/deployment/go.sum
index de265950030..1b058cb711c 100644
--- a/deployment/go.sum
+++ b/deployment/go.sum
@@ -1436,6 +1436,8 @@ github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d/go.mod h1:5JdppgngCOUS76p61zCinSCgOhPeYQ+OcDUuome5THQ=
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260420204255-a3f3bdd56877 h1:6UueUIbck1Ogarm9rm/9TS6b09mKgMmx+YE8XFg63AQ=
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260420204255-a3f3bdd56877/go.mod h1:Jqt53s27Tr0jDl8mdBXg1xhu6F8Fci8JOuq43tgHOM8=
+github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a h1:UPejHeV2qjuZxc9TLa6d0q1KbC9oW29eBWFSynVvXv8=
+github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a/go.mod h1:vL1bDgPSJjV0EqHYs4dDlR+EEE0cJchgvGLYXhwIjXY=
github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0 h1:q+VDPcxWrj5k9QizSYfUOSMnDH3Sd5HvbPguZOgfXTY=
github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0/go.mod h1:/dVVLXrsp+V0AbcYGJo3XMzKg3CkELsweA/TTopCsKE=
github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b h1:QuI6SmQFK/zyUlVWEf0GMkiUYBPY4lssn26nKSd/bOM=
diff --git a/docs/CONFIG.md b/docs/CONFIG.md
index d0487cd90bb..b343cfe6b62 100644
--- a/docs/CONFIG.md
+++ b/docs/CONFIG.md
@@ -2667,6 +2667,40 @@ IgnoreJoblessBridges = false # Default
```
IgnoreJoblessBridges skips bridges that have no associated jobs.
+## JobSpecReporter
+```toml
+[JobSpecReporter]
+Enabled = false # Default
+PollingInterval = "1h" # Default
+EnabledOCR2PluginTypes = ["median"] # Default
+EmitNonOCR2Jobs = false # Default
+```
+JobSpecReporter holds settings for the Job Spec Reporter service, which periodically emits job spec telemetry.
+
+### Enabled
+```toml
+Enabled = false # Default
+```
+Enabled enables the Job Spec Reporter service.
+
+### PollingInterval
+```toml
+PollingInterval = "1h" # Default
+```
+PollingInterval is how often to emit a heartbeat event for each tracked job.
+
+### EnabledOCR2PluginTypes
+```toml
+EnabledOCR2PluginTypes = ["median"] # Default
+```
+EnabledOCR2PluginTypes restricts OCR2 telemetry to jobs with these plugin types.
+
+### EmitNonOCR2Jobs
+```toml
+EmitNonOCR2Jobs = false # Default
+```
+EmitNonOCR2Jobs emits telemetry for non-OCR2 job types (OCR1, Flux Monitor, Keeper).
+
## CRE
```toml
[CRE]
diff --git a/go.md b/go.md
index 6af15e44716..b139573b8be 100644
--- a/go.md
+++ b/go.md
@@ -124,6 +124,8 @@ flowchart LR
click chainlink-protos/chainlink-ccv/verifier href "https://github.com/smartcontractkit/chainlink-protos"
chainlink-protos/cre/go --> chain-selectors
click chainlink-protos/cre/go href "https://github.com/smartcontractkit/chainlink-protos"
+ chainlink-protos/data-feeds
+ click chainlink-protos/data-feeds href "https://github.com/smartcontractkit/chainlink-protos"
chainlink-protos/job-distributor
click chainlink-protos/job-distributor href "https://github.com/smartcontractkit/chainlink-protos"
chainlink-protos/linking-service/go
@@ -168,6 +170,7 @@ flowchart LR
chainlink/v2 --> chainlink-ccv
chainlink/v2 --> chainlink-evm/contracts/cre/gobindings
chainlink/v2 --> chainlink-feeds
+ chainlink/v2 --> chainlink-protos/data-feeds
chainlink/v2 --> chainlink-protos/ring/go
chainlink/v2 --> cre-sdk-go/capabilities/networking/http
chainlink/v2 --> cre-sdk-go/capabilities/scheduler/cron
@@ -244,6 +247,7 @@ flowchart LR
chainlink-protos/chainlink-ccv/message-discovery
chainlink-protos/chainlink-ccv/verifier
chainlink-protos/cre/go
+ chainlink-protos/data-feeds
chainlink-protos/job-distributor
chainlink-protos/linking-service/go
chainlink-protos/node-platform
@@ -402,6 +406,8 @@ flowchart LR
click chainlink-protos/chainlink-ccv/verifier href "https://github.com/smartcontractkit/chainlink-protos"
chainlink-protos/cre/go --> chain-selectors
click chainlink-protos/cre/go href "https://github.com/smartcontractkit/chainlink-protos"
+ chainlink-protos/data-feeds
+ click chainlink-protos/data-feeds href "https://github.com/smartcontractkit/chainlink-protos"
chainlink-protos/job-distributor
click chainlink-protos/job-distributor href "https://github.com/smartcontractkit/chainlink-protos"
chainlink-protos/linking-service/go
@@ -570,6 +576,7 @@ flowchart LR
chainlink/v2 --> chainlink-ccv
chainlink/v2 --> chainlink-evm/contracts/cre/gobindings
chainlink/v2 --> chainlink-feeds
+ chainlink/v2 --> chainlink-protos/data-feeds
chainlink/v2 --> chainlink-protos/ring/go
chainlink/v2 --> cre-sdk-go/capabilities/networking/http
chainlink/v2 --> cre-sdk-go/capabilities/scheduler/cron
@@ -689,6 +696,7 @@ flowchart LR
chainlink-protos/chainlink-ccv/message-discovery
chainlink-protos/chainlink-ccv/verifier
chainlink-protos/cre/go
+ chainlink-protos/data-feeds
chainlink-protos/job-distributor
chainlink-protos/linking-service/go
chainlink-protos/node-platform
diff --git a/go.mod b/go.mod
index 8e6996fbccf..6e6ff7eb5da 100644
--- a/go.mod
+++ b/go.mod
@@ -97,6 +97,7 @@ require (
github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260410144512-ca02ad6ed16a
github.com/smartcontractkit/chainlink-protos/billing/go v0.0.0-20251024234028-0988426d98f4
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260420204255-a3f3bdd56877
+ github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a
github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b
github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260319180422-b5808c964785
github.com/smartcontractkit/chainlink-protos/orchestrator v0.10.0
diff --git a/go.sum b/go.sum
index cc48b7bd28e..1159f6f9b88 100644
--- a/go.sum
+++ b/go.sum
@@ -1282,6 +1282,8 @@ github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d/go.mod h1:5JdppgngCOUS76p61zCinSCgOhPeYQ+OcDUuome5THQ=
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260420204255-a3f3bdd56877 h1:6UueUIbck1Ogarm9rm/9TS6b09mKgMmx+YE8XFg63AQ=
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260420204255-a3f3bdd56877/go.mod h1:Jqt53s27Tr0jDl8mdBXg1xhu6F8Fci8JOuq43tgHOM8=
+github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a h1:UPejHeV2qjuZxc9TLa6d0q1KbC9oW29eBWFSynVvXv8=
+github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a/go.mod h1:vL1bDgPSJjV0EqHYs4dDlR+EEE0cJchgvGLYXhwIjXY=
github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b h1:QuI6SmQFK/zyUlVWEf0GMkiUYBPY4lssn26nKSd/bOM=
github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b/go.mod h1:qSTSwX3cBP3FKQwQacdjArqv0g6QnukjV4XuzO6UyoY=
github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260319180422-b5808c964785 h1:oli+2uLU6jcrJGCuYFqk3475hiwL17SWlITWLv+tx/w=
diff --git a/integration-tests/go.mod b/integration-tests/go.mod
index b0262691278..cad902b64b5 100644
--- a/integration-tests/go.mod
+++ b/integration-tests/go.mod
@@ -419,6 +419,7 @@ require (
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/message-discovery v0.0.0-20251211142334-5c3421fe2c8d // indirect
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d // indirect
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260420204255-a3f3bdd56877 // indirect
+ github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a // indirect
github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b // indirect
github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260319180422-b5808c964785 // indirect
github.com/smartcontractkit/chainlink-protos/op-catalog v0.0.4 // indirect
diff --git a/integration-tests/go.sum b/integration-tests/go.sum
index d7ea502e549..11d25557baa 100644
--- a/integration-tests/go.sum
+++ b/integration-tests/go.sum
@@ -1421,6 +1421,8 @@ github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d/go.mod h1:5JdppgngCOUS76p61zCinSCgOhPeYQ+OcDUuome5THQ=
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260420204255-a3f3bdd56877 h1:6UueUIbck1Ogarm9rm/9TS6b09mKgMmx+YE8XFg63AQ=
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260420204255-a3f3bdd56877/go.mod h1:Jqt53s27Tr0jDl8mdBXg1xhu6F8Fci8JOuq43tgHOM8=
+github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a h1:UPejHeV2qjuZxc9TLa6d0q1KbC9oW29eBWFSynVvXv8=
+github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a/go.mod h1:vL1bDgPSJjV0EqHYs4dDlR+EEE0cJchgvGLYXhwIjXY=
github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0 h1:q+VDPcxWrj5k9QizSYfUOSMnDH3Sd5HvbPguZOgfXTY=
github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0/go.mod h1:/dVVLXrsp+V0AbcYGJo3XMzKg3CkELsweA/TTopCsKE=
github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b h1:QuI6SmQFK/zyUlVWEf0GMkiUYBPY4lssn26nKSd/bOM=
diff --git a/integration-tests/load/go.mod b/integration-tests/load/go.mod
index b317f1a5688..7b7d4b0b0f1 100644
--- a/integration-tests/load/go.mod
+++ b/integration-tests/load/go.mod
@@ -497,6 +497,7 @@ require (
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/message-discovery v0.0.0-20251211142334-5c3421fe2c8d // indirect
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d // indirect
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260420204255-a3f3bdd56877 // indirect
+ github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a // indirect
github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0 // indirect
github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b // indirect
github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260319180422-b5808c964785 // indirect
diff --git a/integration-tests/load/go.sum b/integration-tests/load/go.sum
index 6f2865428ce..646277ace70 100644
--- a/integration-tests/load/go.sum
+++ b/integration-tests/load/go.sum
@@ -1689,6 +1689,8 @@ github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d/go.mod h1:5JdppgngCOUS76p61zCinSCgOhPeYQ+OcDUuome5THQ=
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260420204255-a3f3bdd56877 h1:6UueUIbck1Ogarm9rm/9TS6b09mKgMmx+YE8XFg63AQ=
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260420204255-a3f3bdd56877/go.mod h1:Jqt53s27Tr0jDl8mdBXg1xhu6F8Fci8JOuq43tgHOM8=
+github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a h1:UPejHeV2qjuZxc9TLa6d0q1KbC9oW29eBWFSynVvXv8=
+github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a/go.mod h1:vL1bDgPSJjV0EqHYs4dDlR+EEE0cJchgvGLYXhwIjXY=
github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0 h1:q+VDPcxWrj5k9QizSYfUOSMnDH3Sd5HvbPguZOgfXTY=
github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0/go.mod h1:/dVVLXrsp+V0AbcYGJo3XMzKg3CkELsweA/TTopCsKE=
github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b h1:QuI6SmQFK/zyUlVWEf0GMkiUYBPY4lssn26nKSd/bOM=
diff --git a/system-tests/lib/go.mod b/system-tests/lib/go.mod
index ce013d03e53..08791a08dd8 100644
--- a/system-tests/lib/go.mod
+++ b/system-tests/lib/go.mod
@@ -473,6 +473,7 @@ require (
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/heartbeat v0.0.0-20260115142640-f6b99095c12e // indirect
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/message-discovery v0.0.0-20251211142334-5c3421fe2c8d // indirect
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d // indirect
+ github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a // indirect
github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260319180422-b5808c964785 // indirect
github.com/smartcontractkit/chainlink-protos/orchestrator v0.10.0 // indirect
github.com/smartcontractkit/chainlink-protos/ring/go v0.0.0-20260331131315-f08a616d8dcd // indirect
diff --git a/system-tests/lib/go.sum b/system-tests/lib/go.sum
index ec1c7e40f4b..d7f6fcaed36 100644
--- a/system-tests/lib/go.sum
+++ b/system-tests/lib/go.sum
@@ -1657,6 +1657,8 @@ github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d/go.mod h1:5JdppgngCOUS76p61zCinSCgOhPeYQ+OcDUuome5THQ=
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260420204255-a3f3bdd56877 h1:6UueUIbck1Ogarm9rm/9TS6b09mKgMmx+YE8XFg63AQ=
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260420204255-a3f3bdd56877/go.mod h1:Jqt53s27Tr0jDl8mdBXg1xhu6F8Fci8JOuq43tgHOM8=
+github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a h1:UPejHeV2qjuZxc9TLa6d0q1KbC9oW29eBWFSynVvXv8=
+github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a/go.mod h1:vL1bDgPSJjV0EqHYs4dDlR+EEE0cJchgvGLYXhwIjXY=
github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0 h1:q+VDPcxWrj5k9QizSYfUOSMnDH3Sd5HvbPguZOgfXTY=
github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0/go.mod h1:/dVVLXrsp+V0AbcYGJo3XMzKg3CkELsweA/TTopCsKE=
github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b h1:QuI6SmQFK/zyUlVWEf0GMkiUYBPY4lssn26nKSd/bOM=
diff --git a/system-tests/tests/go.mod b/system-tests/tests/go.mod
index a4d40d8d283..9930078b29a 100644
--- a/system-tests/tests/go.mod
+++ b/system-tests/tests/go.mod
@@ -156,6 +156,7 @@ require (
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/heartbeat v0.0.0-20260115142640-f6b99095c12e // indirect
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/message-discovery v0.0.0-20251211142334-5c3421fe2c8d // indirect
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d // indirect
+ github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a // indirect
github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260319180422-b5808c964785 // indirect
github.com/smartcontractkit/chainlink-solana v1.1.2-0.20260421131224-c46cbfe7bc6c // indirect
github.com/smartcontractkit/chainlink-solana/contracts v0.0.0-20260421131224-c46cbfe7bc6c // indirect
diff --git a/system-tests/tests/go.sum b/system-tests/tests/go.sum
index ca1d6d96b66..b7ee19215b0 100644
--- a/system-tests/tests/go.sum
+++ b/system-tests/tests/go.sum
@@ -1872,6 +1872,8 @@ github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251
github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d/go.mod h1:5JdppgngCOUS76p61zCinSCgOhPeYQ+OcDUuome5THQ=
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260420204255-a3f3bdd56877 h1:6UueUIbck1Ogarm9rm/9TS6b09mKgMmx+YE8XFg63AQ=
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260420204255-a3f3bdd56877/go.mod h1:Jqt53s27Tr0jDl8mdBXg1xhu6F8Fci8JOuq43tgHOM8=
+github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a h1:UPejHeV2qjuZxc9TLa6d0q1KbC9oW29eBWFSynVvXv8=
+github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a/go.mod h1:vL1bDgPSJjV0EqHYs4dDlR+EEE0cJchgvGLYXhwIjXY=
github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0 h1:q+VDPcxWrj5k9QizSYfUOSMnDH3Sd5HvbPguZOgfXTY=
github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0/go.mod h1:/dVVLXrsp+V0AbcYGJo3XMzKg3CkELsweA/TTopCsKE=
github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b h1:QuI6SmQFK/zyUlVWEf0GMkiUYBPY4lssn26nKSd/bOM=
diff --git a/testdata/scripts/config/merge_raw_configs.txtar b/testdata/scripts/config/merge_raw_configs.txtar
index f61352bb1f0..0036d04f4be 100644
--- a/testdata/scripts/config/merge_raw_configs.txtar
+++ b/testdata/scripts/config/merge_raw_configs.txtar
@@ -546,6 +546,12 @@ PollingInterval = '5m0s'
IgnoreInvalidBridges = true
IgnoreJoblessBridges = false
+[JobSpecReporter]
+Enabled = false
+PollingInterval = '1h0m0s'
+EnabledOCR2PluginTypes = ['median']
+EmitNonOCR2Jobs = false
+
[Sharding]
ShardingEnabled = false
ArbiterPort = 9876
diff --git a/testdata/scripts/health/default.txtar b/testdata/scripts/health/default.txtar
index 675d2561a79..9fa76e1db1d 100644
--- a/testdata/scripts/health/default.txtar
+++ b/testdata/scripts/health/default.txtar
@@ -37,6 +37,7 @@ ok CRE.DispatcherWrapper
ok HeadReporter
ok Heartbeat
ok JobSpawner
+ok JobSpecReporter
ok LLOTransmissionReaper
ok Mailbox.Monitor
ok Mercury.WSRPCPool
@@ -106,6 +107,15 @@ ok WorkflowStore
"output": ""
}
},
+ {
+ "type": "checks",
+ "id": "JobSpecReporter",
+ "attributes": {
+ "name": "JobSpecReporter",
+ "status": "passing",
+ "output": ""
+ }
+ },
{
"type": "checks",
"id": "LLOTransmissionReaper",
diff --git a/testdata/scripts/health/multi-chain-loopp.txtar b/testdata/scripts/health/multi-chain-loopp.txtar
index 561bcd58a82..616d5f8e8ce 100644
--- a/testdata/scripts/health/multi-chain-loopp.txtar
+++ b/testdata/scripts/health/multi-chain-loopp.txtar
@@ -138,6 +138,7 @@ ok EVM.1.RelayerService.PluginRelayerClient.PluginEVM.Txm.WrappedEvmEstimator
ok HeadReporter
ok Heartbeat
ok JobSpawner
+ok JobSpecReporter
ok LLOTransmissionReaper
ok Mailbox.Monitor
ok Mercury.WSRPCPool
@@ -499,6 +500,15 @@ ok WorkflowStore
"output": ""
}
},
+ {
+ "type": "checks",
+ "id": "JobSpecReporter",
+ "attributes": {
+ "name": "JobSpecReporter",
+ "status": "passing",
+ "output": ""
+ }
+ },
{
"type": "checks",
"id": "LLOTransmissionReaper",
diff --git a/testdata/scripts/health/multi-chain.txtar b/testdata/scripts/health/multi-chain.txtar
index ad456fc2db8..a3300d75b24 100644
--- a/testdata/scripts/health/multi-chain.txtar
+++ b/testdata/scripts/health/multi-chain.txtar
@@ -74,6 +74,7 @@ ok EVM.1.Txm.WrappedEvmEstimator
ok HeadReporter
ok Heartbeat
ok JobSpawner
+ok JobSpecReporter
ok LLOTransmissionReaper
ok Mailbox.Monitor
ok Mercury.WSRPCPool
@@ -268,6 +269,15 @@ ok WorkflowStore
"output": ""
}
},
+ {
+ "type": "checks",
+ "id": "JobSpecReporter",
+ "attributes": {
+ "name": "JobSpecReporter",
+ "status": "passing",
+ "output": ""
+ }
+ },
{
"type": "checks",
"id": "LLOTransmissionReaper",
diff --git a/testdata/scripts/node/validate/default.txtar b/testdata/scripts/node/validate/default.txtar
index 8c68e3e8080..df7110f96a7 100644
--- a/testdata/scripts/node/validate/default.txtar
+++ b/testdata/scripts/node/validate/default.txtar
@@ -411,6 +411,12 @@ PollingInterval = '5m0s'
IgnoreInvalidBridges = true
IgnoreJoblessBridges = false
+[JobSpecReporter]
+Enabled = false
+PollingInterval = '1h0m0s'
+EnabledOCR2PluginTypes = ['median']
+EmitNonOCR2Jobs = false
+
[Sharding]
ShardingEnabled = false
ArbiterPort = 9876
diff --git a/testdata/scripts/node/validate/defaults-override.txtar b/testdata/scripts/node/validate/defaults-override.txtar
index eb5bcfa1d05..8a2dc3ef52e 100644
--- a/testdata/scripts/node/validate/defaults-override.txtar
+++ b/testdata/scripts/node/validate/defaults-override.txtar
@@ -472,6 +472,12 @@ PollingInterval = '5m0s'
IgnoreInvalidBridges = true
IgnoreJoblessBridges = false
+[JobSpecReporter]
+Enabled = false
+PollingInterval = '1h0m0s'
+EnabledOCR2PluginTypes = ['median']
+EmitNonOCR2Jobs = false
+
[Sharding]
ShardingEnabled = false
ArbiterPort = 9876
diff --git a/testdata/scripts/node/validate/disk-based-logging-disabled.txtar b/testdata/scripts/node/validate/disk-based-logging-disabled.txtar
index c3931840482..d5f523a9d10 100644
--- a/testdata/scripts/node/validate/disk-based-logging-disabled.txtar
+++ b/testdata/scripts/node/validate/disk-based-logging-disabled.txtar
@@ -455,6 +455,12 @@ PollingInterval = '5m0s'
IgnoreInvalidBridges = true
IgnoreJoblessBridges = false
+[JobSpecReporter]
+Enabled = false
+PollingInterval = '1h0m0s'
+EnabledOCR2PluginTypes = ['median']
+EmitNonOCR2Jobs = false
+
[Sharding]
ShardingEnabled = false
ArbiterPort = 9876
diff --git a/testdata/scripts/node/validate/disk-based-logging-no-dir.txtar b/testdata/scripts/node/validate/disk-based-logging-no-dir.txtar
index 0efe1f681bf..af76ab00099 100644
--- a/testdata/scripts/node/validate/disk-based-logging-no-dir.txtar
+++ b/testdata/scripts/node/validate/disk-based-logging-no-dir.txtar
@@ -455,6 +455,12 @@ PollingInterval = '5m0s'
IgnoreInvalidBridges = true
IgnoreJoblessBridges = false
+[JobSpecReporter]
+Enabled = false
+PollingInterval = '1h0m0s'
+EnabledOCR2PluginTypes = ['median']
+EmitNonOCR2Jobs = false
+
[Sharding]
ShardingEnabled = false
ArbiterPort = 9876
diff --git a/testdata/scripts/node/validate/disk-based-logging.txtar b/testdata/scripts/node/validate/disk-based-logging.txtar
index 5250f258f34..3c080882268 100644
--- a/testdata/scripts/node/validate/disk-based-logging.txtar
+++ b/testdata/scripts/node/validate/disk-based-logging.txtar
@@ -455,6 +455,12 @@ PollingInterval = '5m0s'
IgnoreInvalidBridges = true
IgnoreJoblessBridges = false
+[JobSpecReporter]
+Enabled = false
+PollingInterval = '1h0m0s'
+EnabledOCR2PluginTypes = ['median']
+EmitNonOCR2Jobs = false
+
[Sharding]
ShardingEnabled = false
ArbiterPort = 9876
diff --git a/testdata/scripts/node/validate/fallback-override.txtar b/testdata/scripts/node/validate/fallback-override.txtar
index d481c180565..2d90fdd5685 100644
--- a/testdata/scripts/node/validate/fallback-override.txtar
+++ b/testdata/scripts/node/validate/fallback-override.txtar
@@ -553,6 +553,12 @@ PollingInterval = '5m0s'
IgnoreInvalidBridges = true
IgnoreJoblessBridges = false
+[JobSpecReporter]
+Enabled = false
+PollingInterval = '1h0m0s'
+EnabledOCR2PluginTypes = ['median']
+EmitNonOCR2Jobs = false
+
[Sharding]
ShardingEnabled = false
ArbiterPort = 9876
diff --git a/testdata/scripts/node/validate/invalid-ocr-p2p.txtar b/testdata/scripts/node/validate/invalid-ocr-p2p.txtar
index 8b92ebfd791..43c4fe73c52 100644
--- a/testdata/scripts/node/validate/invalid-ocr-p2p.txtar
+++ b/testdata/scripts/node/validate/invalid-ocr-p2p.txtar
@@ -440,6 +440,12 @@ PollingInterval = '5m0s'
IgnoreInvalidBridges = true
IgnoreJoblessBridges = false
+[JobSpecReporter]
+Enabled = false
+PollingInterval = '1h0m0s'
+EnabledOCR2PluginTypes = ['median']
+EmitNonOCR2Jobs = false
+
[Sharding]
ShardingEnabled = false
ArbiterPort = 9876
diff --git a/testdata/scripts/node/validate/invalid.txtar b/testdata/scripts/node/validate/invalid.txtar
index e245512e424..d552c5f70c8 100644
--- a/testdata/scripts/node/validate/invalid.txtar
+++ b/testdata/scripts/node/validate/invalid.txtar
@@ -451,6 +451,12 @@ PollingInterval = '5m0s'
IgnoreInvalidBridges = true
IgnoreJoblessBridges = false
+[JobSpecReporter]
+Enabled = false
+PollingInterval = '1h0m0s'
+EnabledOCR2PluginTypes = ['median']
+EmitNonOCR2Jobs = false
+
[Sharding]
ShardingEnabled = false
ArbiterPort = 9876
diff --git a/testdata/scripts/node/validate/valid.txtar b/testdata/scripts/node/validate/valid.txtar
index 8db0b37ffaf..f2941e4d1c8 100644
--- a/testdata/scripts/node/validate/valid.txtar
+++ b/testdata/scripts/node/validate/valid.txtar
@@ -452,6 +452,12 @@ PollingInterval = '5m0s'
IgnoreInvalidBridges = true
IgnoreJoblessBridges = false
+[JobSpecReporter]
+Enabled = false
+PollingInterval = '1h0m0s'
+EnabledOCR2PluginTypes = ['median']
+EmitNonOCR2Jobs = false
+
[Sharding]
ShardingEnabled = false
ArbiterPort = 9876
diff --git a/testdata/scripts/node/validate/warnings.txtar b/testdata/scripts/node/validate/warnings.txtar
index 3ab643dd95b..55d8b01019b 100644
--- a/testdata/scripts/node/validate/warnings.txtar
+++ b/testdata/scripts/node/validate/warnings.txtar
@@ -434,6 +434,12 @@ PollingInterval = '5m0s'
IgnoreInvalidBridges = true
IgnoreJoblessBridges = false
+[JobSpecReporter]
+Enabled = false
+PollingInterval = '1h0m0s'
+EnabledOCR2PluginTypes = ['median']
+EmitNonOCR2Jobs = false
+
[Sharding]
ShardingEnabled = false
ArbiterPort = 9876